chore(studio): integrations Marketplace layout (#45856)

Introducing a new Integrations Marketplace layout
- behind feature flag
- behind opt-out feature preview (default to true) to easily toggle
before and after

Doesn't introduce new functionality, only layout change behind ff.

## What changed

Nothing if `marketplaceIntegrations` flag is off.

### With flag

- New Marketplace index layout
  - filter via text or by `category`, `integration type` and `source`
- Featured Partners Integrations hero (hidden when any filter is active)
  - toggle between `grid` and `list` view
- new integration detail page layout
  - with top action bar
  - more consistent fullWidth or narrow content layouts
- can access Feature Preview toggle under Account Dropdown > Feature
Previews > Marketplace

## How to review and what to test

I recommend reviewing file changes from bottom to top in
https://github.com/supabase/supabase/pull/45856/changes because it makes
more sense from a pr-logic perspective.

- [ ] Visit
https://studio-staging-git-chore-integrations-ui-refine-fs-supabase.vercel.app/dashboard/project/_/integrations
- [ ] Check if new layout doesn't have big layout issues like weird
paddings or margins mostly. (We can iterate in upcoming PRs for
additional features/fixes)
- [ ] Toggle "Marketplace" feature preview to `FALSE` and check that the
Integrations layout looks like in prod
https://supabase.com/dashboard/project/_/integrations
- [ ] Also check individual integration pages and toggle the feature
preview on and off to see before and after and check if everything looks
good

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Full Marketplace experience: explorer with featured hero,
filters/search, list & grid views, cards, sidebar, detailed integration
pages, install flows, badges, and a preview toggle.

* **Bug Fixes**
  * Prevented stale markdown from showing when switching integrations.

* **Style**
* Improved responsive layouts, loading placeholders, and header/nav
full-width behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45856)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Saxon Fletcher <saxonafletcher@gmail.com>
Co-authored-by: Raminder Singh <romi_ssk@yahoo.co.in>
This commit is contained in:
authored and GitHub committed 2026-05-20 11:01:32 +02:00
1 parent df61856936
commit dc986674d9
46 files changed
+2060 -285

No files matched your search

@@ -125,6 +125,12 @@ export const useIsRLSTesterEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]
}
export const useIsMarketplaceEnabled = () => {
const { flags } = useFeaturePreviewContext()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
return isMarketplaceEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]
}
export const useFeaturePreviewModal = () => {
const featurePreviews = useFeaturePreviews()
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
@@ -26,6 +26,7 @@ import { AdvisorRulesPreview } from './AdvisorRulesPreview'
import { CLSPreview } from './CLSPreview'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
import { JitDbAccessPreview } from './JitDbAccessPreview'
import { MarketplacePreview } from './MarketplacePreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
import { RLSTesterPreview } from './RLSTesterPreview'
@@ -47,6 +48,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: <JitDbAccessPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]: <RLSTesterPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: <MarketplacePreview />,
}
export const FeaturePreviewModal = () => {
@@ -0,0 +1,12 @@
export const MarketplacePreview = () => (
<div>
<p className="text-sm text-foreground-light mb-4">
Browse and install integrations from a new Marketplace layout with improved filtering and
search.
</p>
<p className="text-sm text-foreground-light">
This preview is on by default for internal teammates and select integration partners. Turn it
off to fall back to the previous Integrations layout at any time.
</p>
</div>
)
@@ -84,5 +84,14 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isPlatformOnly: false,
isDefaultOptIn: false,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
name: 'Marketplace',
discussionsUrl: undefined,
enabled: true,
isNew: true,
isPlatformOnly: false,
isDefaultOptIn: true,
},
].sort((a, b) => Number(b.isNew) - Number(a.isNew))
}
@@ -0,0 +1,20 @@
import { PropsWithChildren } from 'react'
import { cn } from 'ui'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface ConstrainedIntegrationTabScaffoldProps extends PropsWithChildren {
className?: string
}
export const ConstrainedIntegrationTabScaffold = ({
children,
className,
}: ConstrainedIntegrationTabScaffoldProps) => {
const isMarketplace = useIsMarketplaceEnabled()
return (
<div className={cn('w-full py-6 xl:py-10', !isMarketplace && 'px-6 xl:px-10', className)}>
{children}
</div>
)
}
@@ -1,10 +1,11 @@
import { useFlag, useParams } from 'common'
import { useParams } from 'common'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertTitle, cn } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch'
import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard'
import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
@@ -49,7 +50,7 @@ const DataApiOverview = () => {
}
export const DataApiOverviewTab = () => {
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return (
@@ -1,6 +1,6 @@
import { useParams } from 'common'
import { PageContainer } from 'ui-patterns'
import { ConstrainedIntegrationTabScaffold } from '../ConstrainedIntegrationTabScaffold'
import { DataApiDisabledState } from '@/components/interfaces/Integrations/DataApi/DataApiDisabledState'
import { ServiceList } from '@/components/interfaces/Settings/API/ServiceList'
import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
@@ -11,12 +11,16 @@ export const DataApiSettingsTab = () => {
const { isEnabled, isPending } = useIsDataApiEnabled({ projectRef })
if (IS_PLATFORM && !isPending && !isEnabled) {
return <DataApiDisabledState description="configure settings" />
return (
<ConstrainedIntegrationTabScaffold>
<DataApiDisabledState description="configure settings" />
</ConstrainedIntegrationTabScaffold>
)
}
return (
<PageContainer size="default" className="ml-0">
<ConstrainedIntegrationTabScaffold>
<ServiceList />
</PageContainer>
</ConstrainedIntegrationTabScaffold>
)
}
@@ -0,0 +1,25 @@
import { cn } from 'ui'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
interface IntegrationLogoProps {
integration: Pick<IntegrationDefinition, 'icon'>
size?: string
className?: string
}
export const IntegrationLogo = ({
integration,
size = 'h-9 w-9',
className,
}: IntegrationLogoProps) => (
<div
className={cn(
'relative flex shrink-0 items-center justify-center overflow-hidden rounded-md border bg-white',
size,
className
)}
>
{integration.icon()}
</div>
)
@@ -13,15 +13,26 @@ export const MarkdownContent = ({
}: MarkdownContentProps) => {
const [localContent, setLocalContent] = useState<string>('')
const content = remoteContent || localContent
useEffect(() => {
if (!!integrationId && !content) {
import(`@/static-data/integrations/${integrationId}/overview.md`)
.then((module) => setLocalContent(String(module.default)))
.catch((error) => console.error('Error loading markdown:', error))
// Reset on every id/remote change so navigating between integrations
// doesn't show the previous one's overview while the new import resolves.
setLocalContent('')
if (!integrationId || remoteContent) return
let cancelled = false
import(`@/static-data/integrations/${integrationId}/overview.md`)
.then((module) => {
if (!cancelled) setLocalContent(String(module.default))
})
.catch((error) => console.error('Error loading markdown:', error))
return () => {
cancelled = true
}
}, [integrationId, content])
}, [integrationId, remoteContent])
const content = remoteContent || localContent
return <Markdown className="flex flex-col gap-y-4 text-foreground-light">{content}</Markdown>
}
@@ -3,16 +3,16 @@
* on the marketplace feature flag
*/
import { useFlag } from 'common'
import { PropsWithChildren } from 'react'
import { IntegrationOverviewTab, IntegrationOverviewTabProps } from './IntegrationOverviewTab'
import { IntegrationOverviewTabV2 } from './IntegrationOverviewTabV2'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const IntegrationOverviewTabWrapper = (
props: PropsWithChildren<IntegrationOverviewTabProps>
) => {
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return <IntegrationOverviewTabV2>{props.children}</IntegrationOverviewTabV2>
@@ -0,0 +1,143 @@
import Link from 'next/link'
import { useMemo } from 'react'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
NavMenu,
NavMenuItem,
} from 'ui'
import {
Admonition,
PageContainer,
PageHeader,
PageHeaderBreadcrumb,
PageHeaderDescription,
PageHeaderIcon,
PageHeaderMeta,
PageHeaderNavigationTabs,
PageHeaderSummary,
PageHeaderTitle,
PageSection,
PageSectionContent,
} from 'ui-patterns'
import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { IntegrationLogo } from '@/components/interfaces/Integrations/Integration/IntegrationLogo'
import { InstallOAuthIntegrationButton } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton'
import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
const LegacyIntegrationPage = () => {
const {
ref,
id,
isReady,
isWrapperBlocked,
pageTitle,
pageSubTitle,
integration,
isAvailableLoading,
isInstalledLoading,
tabs,
Component,
} = useIntegrationDetail()
const content = useMemo(() => {
if (!isReady || isInstalledLoading || isAvailableLoading) {
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
<GenericSkeletonLoader />
</PageSectionContent>
</PageSection>
</PageContainer>
)
} else if (!Component || !id || !integration) {
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
<Admonition type="warning" title="This integration is not currently available">
Please try again later or contact support if the problem persists.
</Admonition>
</PageSectionContent>
</PageSection>
</PageContainer>
)
} else {
return <Component />
}
}, [isReady, isInstalledLoading, isAvailableLoading, id, integration, Component])
if (!isReady) return null
if (isWrapperBlocked) return <UnknownInterface urlBack={`/project/${ref}/integrations`} />
return (
<>
<PageHeader size="full">
<PageHeaderBreadcrumb className="mx-auto w-full">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/project/${ref}/integrations`}>Integrations</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{integration?.name || 'Integration not found'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageHeaderBreadcrumb>
{isAvailableLoading ? (
<PageHeaderMeta className="mx-auto w-full">
<PageHeaderSummary>
<PageHeaderTitle>
<ShimmeringLoader className="w-64 py-4" />
</PageHeaderTitle>
<PageHeaderDescription>
<ShimmeringLoader />
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
) : (
<PageHeaderMeta className="mx-auto w-full">
{integration && (
<PageHeaderIcon>
<IntegrationLogo integration={integration} size="w-14 h-14" />
</PageHeaderIcon>
)}
<PageHeaderSummary className="gap-y-0.5">
<PageHeaderTitle>{pageTitle}</PageHeaderTitle>
<PageHeaderDescription>{pageSubTitle}</PageHeaderDescription>
</PageHeaderSummary>
{integration?.type === 'oauth' && (
<InstallOAuthIntegrationButton integration={integration} />
)}
</PageHeaderMeta>
)}
{tabs.length > 0 && (
<PageHeaderNavigationTabs className="mx-auto w-full">
<NavMenu>
{tabs.map((tab) => (
<NavMenuItem key={tab.href} active={tab.active}>
<Link href={tab.href}>{tab.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</PageHeaderNavigationTabs>
)}
</PageHeader>
<div className="mx-auto w-full">{content}</div>
</>
)
}
export default LegacyIntegrationPage
@@ -22,7 +22,7 @@ export const IntegrationLoadingCard = () => {
return (
<div className={cn(INTEGRATION_CARD_STYLE, 'pl-5 pr-6 py-3 gap-3 inline-flex h-[110px]')}>
<div className="w-10 h-10 relative">
<ShimmeringLoader className="w-full h-full bg-white border rounded-md" />
<ShimmeringLoader className="w-full h-full bg-foreground-light border rounded-md" />
</div>
<div className="grow basis-0 w-full flex flex-col justify-between items-start gap-y-2">
<div className="w-full flex-col justify-start items-start gap-y-1 flex">
@@ -71,7 +71,7 @@ export const IntegrationCard = ({
<CardContent className="p-6 px-4">
<div className="flex-col justify-start items-center text-center gap-y-0.5 flex">
<h3>{name}</h3>
<p className="text-foreground-light text-sm">{description}</p>
<p className="text-foreground-light text-sm line-clamp-3">{description}</p>
<div className="flex items-center gap-x-1 mt-4">
{status && <Badge variant="warning">{status}</Badge>}
{shouldShowOfficialBadge && <Badge>Official</Badge>}
@@ -20,12 +20,15 @@ import { getQueryClient } from '@/data/query-client'
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
export type NavigationContentLayout = 'constrained' | 'full'
export type Navigation = {
route: string
label: string
hasChild?: boolean
childIcon?: React.ReactNode
children?: Navigation[]
layout?: NavigationContentLayout // applies only to the new marketplace
}
// [Joshen] Basing this on template.json for now
@@ -51,6 +54,8 @@ type Listing = Tables<'listings'>
type InstallUrlType = NonNullable<Listing['installation_url_type']>
type InstallIdentificationMethod = NonNullable<Listing['installation_identification_method']>
export type MarketplaceSource = 'Official' | 'Partner' | 'Community'
/**
* [Joshen] For marketplace, we probably need to revisit this definition
* What properties are obsolete, what properties we need from remote source
@@ -71,6 +76,8 @@ export type IntegrationDefinition = {
name: string
websiteUrl: string
}
/** Provenance of the integration — Official (built by Supabase), Partner (formal third-party listing), Community (open-source, not officially endorsed). */
source: MarketplaceSource
requiredExtensions: Array<string>
/** Optional component to render if the integration requires extensions that are not available on the current database image */
missingExtensionsAlert?: ReactNode
@@ -125,6 +132,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'queues',
type: 'postgres_extension' as const,
source: 'Community' as const,
requiredExtensions: ['pgmq'],
missingExtensionsAlert: <UpgradeDatabaseAlert minimumVersion="15.6.1.143" />,
name: `Queues`,
@@ -153,6 +161,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
route: 'settings',
label: 'Settings',
layout: 'constrained',
},
],
navigate: ({ pageId = 'overview', childId }) => {
@@ -186,6 +195,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'cron',
type: 'postgres_extension' as const,
source: 'Community' as const,
requiredExtensions: ['pg_cron'],
name: `Cron`,
icon: ({ className, ...props } = {}) => (
@@ -239,6 +249,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'vault',
type: 'postgres_extension' as const,
source: 'Official' as const,
requiredExtensions: ['supabase_vault'],
missingExtensionsAlert: <UpgradeDatabaseAlert />,
name: `Vault`,
@@ -285,6 +296,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'webhooks',
type: 'postgres_extension' as const,
source: 'Official' as const,
name: `Database Webhooks`,
icon: ({ className, ...props } = {}) => (
<Webhook className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
@@ -302,6 +314,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
route: 'webhooks',
label: 'Webhooks',
layout: 'constrained',
},
],
navigate: ({ pageId = 'overview' }) => {
@@ -339,6 +352,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'data_api',
type: 'custom' as const,
source: 'Official' as const,
requiredExtensions: [],
name: `Data API`,
icon: ({ className, ...props } = {}) => (
@@ -355,6 +369,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
route: 'settings',
label: 'Settings',
layout: 'constrained',
},
{
route: 'docs',
@@ -400,6 +415,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'graphiql',
type: 'postgres_extension' as const,
source: 'Official' as const,
requiredExtensions: ['pg_graphql'],
name: `GraphQL`,
icon: ({ className, ...props } = {}) => (
@@ -456,6 +472,7 @@ const WRAPPER_INTEGRATIONS: Array<IntegrationDefinition> = WRAPPERS.map((w) => {
return {
id: w.name,
type: 'wrapper' as const,
source: 'Official' as const,
name: `${w.label} Wrapper`,
icon: ({ className, ...props } = {}) => (
<Image fill src={w.icon} alt={w.name} className={cn('p-2', className)} {...props} />
@@ -507,6 +524,7 @@ const TEMPLATE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
id: 'stripe_sync_engine',
type: 'template' as const,
source: 'Partner' as const,
requiredExtensions: ['pgmq', 'supabase_vault', 'pg_cron', 'pg_net'],
missingExtensionsAlert: <UpgradeDatabaseAlert minimumVersion="15.6.1.143" />,
name: `Stripe Sync Engine`,
@@ -535,6 +553,7 @@ const TEMPLATE_INTEGRATIONS: Array<IntegrationDefinition> = [
{
route: 'settings',
label: 'Settings',
layout: 'constrained',
},
],
navigate: ({ pageId = 'overview' }) => {
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { FeatureFlagContext, IS_PLATFORM, useFlag } from 'common'
import { FeatureFlagContext, IS_PLATFORM } from 'common'
import { fullImageUrl } from 'common/marketplace-client'
import { Boxes } from 'lucide-react'
import dynamic from 'next/dynamic'
@@ -8,6 +8,7 @@ import { useContext, useMemo } from 'react'
import { cn } from 'ui'
import { INTEGRATIONS, Loading, type IntegrationDefinition } from './Integrations.constants'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { marketplaceIntegrationsQueryOptions } from '@/data/marketplace/integrations-query'
import { useCLIReleaseVersionQuery } from '@/data/misc/cli-release-version-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -19,7 +20,7 @@ import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
*/
export const useAvailableIntegrations = () => {
const { hasLoaded } = useContext(FeatureFlagContext)
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const { data: cliData } = useCLIReleaseVersionQuery()
@@ -67,6 +68,7 @@ export const useAvailableIntegrations = () => {
status,
featured: !!featured,
type: 'oauth' as const, // Currently marketplace only supports oauth apps
source: 'Partner' as const,
categories: Array.isArray(categories)
? (categories as Array<{ slug: string }>).map((x) => x.slug)
: [],
@@ -0,0 +1,122 @@
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useEffect, useMemo } from 'react'
import { useAvailableIntegrations } from './useAvailableIntegrations'
import { useInstalledIntegrations } from './useInstalledIntegrations'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export type IntegrationTab = {
label: string
href: string
active: boolean
}
export const useIntegrationDetail = () => {
const router = useRouter()
const { ref, id, pageId, childId } = useParams()
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const { data: project } = useSelectedProjectQuery()
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: allIntegrations, isPending: isAvailableLoading } = useAvailableIntegrations()
const { installedIntegrations, isLoading: isInstalledLoading } = useInstalledIntegrations()
// wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net
const integration = useMemo(() => allIntegrations.find((i) => i.id === id), [allIntegrations, id])
const installation = useMemo(
() => installedIntegrations.find((i) => i.id === id),
[installedIntegrations, id]
)
const installableExtensions = (extensions ?? []).filter((ext) =>
(integration?.requiredExtensions ?? []).includes(ext.name)
)
const extensionsInstalled = installableExtensions.every((x) => x.installed_version)
// installedIntegrations doesn't return wrappers unless there's a wrapper created
const isInstalled =
!!integration && (!!installation || (integration.type === 'wrapper' && extensionsInstalled))
const navItems = useMemo(() => {
if (!integration?.navigation) return []
return isInstalled
? integration.navigation
: integration.navigation.filter((nav) => nav.route === 'overview')
}, [integration, isInstalled])
const activeRoute = pageId ?? 'overview'
const activeNav = useMemo(
() => navItems.find((nav) => nav.route === activeRoute),
[navItems, activeRoute]
)
const isKnownRoute = !!activeNav
const layout = (activeNav?.layout ?? 'full') as 'full' | 'constrained'
const tabs: IntegrationTab[] = useMemo(
() =>
navItems.map((nav) => ({
label: nav.label,
href: `/project/${ref}/integrations/${id}/${nav.route}`,
active: nav.route === activeRoute,
})),
[navItems, ref, id, activeRoute]
)
const Component = useMemo(
() => integration?.navigate({ id, pageId, childId }),
[integration, id, pageId, childId]
)
const isReady = !!router?.isReady
const isWrapperBlocked = !integrationsWrappers && !!id?.endsWith('_wrapper')
const pageTitle = integration?.name ?? 'Integration not found'
const pageSubTitle =
integration?.description ?? 'If you think this is an error, please contact support'
useEffect(() => {
if (
router?.isReady &&
!isAvailableLoading &&
!isInstalledLoading &&
!!integration &&
pageId &&
!isKnownRoute
) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
}, [router, pageId, ref, id, integration, isKnownRoute, isAvailableLoading, isInstalledLoading])
return {
ref,
id,
pageId,
childId,
activeRoute,
activeNav,
isKnownRoute,
layout,
tabs,
isReady,
isWrapperBlocked,
pageTitle,
pageSubTitle,
integrationsWrappers,
integration,
installation,
isInstalled,
isAvailableLoading,
isInstalledLoading,
Component,
}
}
@@ -0,0 +1,167 @@
import {
BadgeCheck,
BarChart3,
Boxes,
Cable,
Cpu,
CreditCard,
Database,
Fingerprint,
KeyRound,
Mail,
MessageSquare,
MousePointerClick,
Package2,
Plug,
Server,
ShieldCheck,
Users,
Webhook,
Wrench,
Zap,
type LucideIcon,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { Badge, cn, IconPartners } from 'ui'
import type {
IntegrationDefinition,
MarketplaceSource,
} from '@/components/interfaces/Integrations/Landing/Integrations.constants'
export type { MarketplaceSource } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
// Launch-partner pin list for the featured rail. Order here is the order
// shown. Stripe is the only first-party template in the set (id matches the
// static catalogue); the rest are marketplaceDB slugs and only render if the
// listing is published.
export const FEATURED_INTEGRATION_IDS = [
'grafana',
'stripe_sync_engine',
'aikido',
'aikido-security',
'doppler',
'cipherstash',
] as const
export type MarketplaceIntegrationType = 'oauth' | 'postgres_extension' | 'template' | 'wrapper'
export const INTEGRATION_TYPES: Array<{
key: MarketplaceIntegrationType
label: string
icon: LucideIcon
}> = [
{ key: 'oauth', label: 'OAuth App', icon: Plug },
{ key: 'postgres_extension', label: 'Postgres Module', icon: Database },
{ key: 'template', label: 'Template', icon: Package2 },
{ key: 'wrapper', label: 'Wrapper', icon: Cable },
]
// Lucide icon per marketplace category slug. New categories fall back to a
// neutral icon (Boxes) so nothing breaks when the marketplaceDB is updated.
export const CATEGORY_ICONS: Record<string, LucideIcon> = {
observability: BarChart3,
security: ShieldCheck,
billing: CreditCard,
secrets: KeyRound,
email: Mail,
wrappers: Database,
ai: Cpu,
ai_vectors: Cpu,
storage: Package2,
postgres_extension: Database,
wrapper: Cable,
devtools: Wrench,
auth: Fingerprint,
'low-code': MousePointerClick,
'data-platform': Server,
api: Webhook,
'caching-offline-first': Zap,
messaging: MessageSquare,
}
export const FEATURED_CATEGORIES: Array<{ slug: string; name: string }> = [
{ slug: 'observability', name: 'Observability' },
{ slug: 'security', name: 'Security' },
{ slug: 'billing', name: 'Billing' },
{ slug: 'devtools', name: 'DevTools' },
{ slug: 'ai_vectors', name: 'AI & Vectors' },
{ slug: 'storage', name: 'Storage' },
]
// Categories excluded from the filter dropdown — they either overlap with
// dedicated filter groups (type/source) or are unused in the current catalogue.
export const EXCLUDED_CATEGORY_SLUGS = new Set([
'agencies',
'foreign-data-wrapper',
'app-templates',
])
export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => {
if (!slug) return Boxes
return CATEGORY_ICONS[slug] ?? Boxes
}
export const formatCategoryLabel = (
slug: string | null | undefined,
categoryOptions?: Array<{ slug: string; name: string }>
): string => {
if (!slug) return ''
const pinned = FEATURED_CATEGORIES.find((c) => c.slug === slug)
if (pinned) return pinned.name
const remote = categoryOptions?.find((c) => c.slug === slug)
if (remote) return remote.name
return slug
.split(/[-_]/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
export const getMarketplaceType = (
integration: IntegrationDefinition
): MarketplaceIntegrationType => {
if (integration.type === 'wrapper') return 'wrapper'
if (integration.type === 'postgres_extension') return 'postgres_extension'
if (integration.id === 'stripe_sync_engine') return 'template'
return 'oauth'
}
export const getMarketplaceTypeLabel = (type: MarketplaceIntegrationType): string =>
INTEGRATION_TYPES.find((t) => t.key === type)?.label ?? type
export const MARKETPLACE_SOURCES: Array<{
key: MarketplaceSource
label: string
icon: LucideIcon | ((props: { className?: string }) => ReactNode)
}> = [
{ key: 'Official', label: 'Official', icon: BadgeCheck },
{ key: 'Partner', label: 'Partner', icon: IconPartners },
{ key: 'Community', label: 'Community', icon: Users },
]
export const getMarketplaceSource = (integration: IntegrationDefinition): MarketplaceSource => {
if (integration.source) return integration.source
return integration.listingId ? 'Partner' : 'Official'
}
export const MarketplaceSourceBadge = ({
source,
className,
}: {
source: MarketplaceSource
className?: string
}) => {
switch (source) {
case 'Partner':
return (
<Badge className={cn('border-foreground-lighter/50', className)}>
<IconPartners size={10} /> Partner
</Badge>
)
case 'Community':
return <Badge className={className}>Community</Badge>
case 'Official':
return <Badge className={className}>Official</Badge>
}
}
@@ -0,0 +1,54 @@
import { useParams } from 'common'
import Link from 'next/link'
import { Badge, Card } from 'ui'
import { IntegrationLogo } from '../Integration/IntegrationLogo'
import { getMarketplaceSource, MarketplaceSourceBadge } from './Marketplace.constants'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
interface MarketplaceCardProps {
integration: IntegrationDefinition
isInstalled: boolean
}
export const MarketplaceCard = ({ integration, isInstalled }: MarketplaceCardProps) => {
const { ref } = useParams()
const source = getMarketplaceSource(integration)
return (
<Link
href={`/project/${ref}/integrations/${integration.id}/overview`}
className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background"
>
<Card className="flex min-h-[168px] h-full flex-col gap-2.5 hover:border-stronger p-4">
<div className="flex items-start justify-between">
<IntegrationLogo integration={integration} size="h-9 w-9" />
{isInstalled && <Badge variant="success">Installed</Badge>}
</div>
<div>
<div className="mb-1 text-sm font-medium">{integration.name}</div>
{integration.description && (
<p className="line-clamp-2 text-xs leading-snug text-foreground-light">
{integration.description}
</p>
)}
</div>
<div className="flex-1" />
<div className="flex items-center justify-between gap-2 pt-2.5">
<div className="flex flex-wrap items-center gap-1">
<MarketplaceSourceBadge source={source} />
{integration.status && (
<Badge variant="warning" className="capitalize">
{integration.status}
</Badge>
)}
</div>
<div className="text-xs flex items-center gap-1 text-foreground-lighter">
<span>Built by</span>
<span>{integration.author?.name}</span>
</div>
</div>
</Card>
</Link>
)
}
@@ -0,0 +1,130 @@
import { ArrowUpRight, BookOpen, Settings } from 'lucide-react'
import { Button, cn } from 'ui'
import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
import { MarketplaceDetailHero } from './MarketplaceDetailHero'
import { MarketplaceDetailTopBar } from './MarketplaceDetailTopBar'
import { OverviewTab } from './OverviewTab'
import { InstallIntegrationSheet } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallIntegrationSheet'
import { InstallOAuthIntegrationButton } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton'
import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
export const centeredContentClass = 'mx-auto w-full max-w-6xl px-6 xl:px-10'
export const MarketplaceDetail = () => {
const {
ref,
activeRoute,
isKnownRoute,
layout,
tabs,
isReady,
isWrapperBlocked,
pageTitle,
pageSubTitle,
integration,
isInstalled,
isAvailableLoading,
isInstalledLoading,
Component,
} = useIntegrationDetail()
if (!isReady) return null
if (isWrapperBlocked) return <UnknownInterface urlBack={`/project/${ref}/integrations`} />
if (isAvailableLoading || isInstalledLoading) {
return (
<>
<MarketplaceDetailTopBar title="" />
<div className={cn(centeredContentClass, 'border-b bg-surface-75 pt-10')}>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 pb-6">
<ShimmeringLoader className="h-9 w-64" />
<ShimmeringLoader className="h-4 w-96" />
</div>
</div>
<div className={cn(centeredContentClass, 'py-8')}>
<GenericSkeletonLoader />
</div>
</>
)
}
if (!integration) {
return (
<>
<MarketplaceDetailTopBar title="Integration not found" />
<div className={cn(centeredContentClass, 'py-8')}>
<Admonition type="warning" title="This integration is not currently available">
Please try again later or contact support if the problem persists.
</Admonition>
</div>
</>
)
}
const renderInstallAction = () => {
if (integration.type === 'oauth') {
return <InstallOAuthIntegrationButton integration={integration} />
}
if (isInstalled) {
return (
<Button type="outline" disabled icon={<Settings size={13} />}>
Installed
</Button>
)
}
return <InstallIntegrationSheet integration={integration} />
}
const CustomPageComponent = activeRoute !== 'overview' && isKnownRoute ? Component : null
return (
<>
<MarketplaceDetailTopBar
title={integration.name}
isInstalled={isInstalled}
actions={
<>
{integration.docsUrl && (
<Button
type="text"
size="tiny"
icon={<BookOpen size={13} />}
iconRight={<ArrowUpRight size={13} />}
asChild
>
<a href={integration.docsUrl} target="_blank" rel="noreferrer">
Docs
</a>
</Button>
)}
{renderInstallAction()}
</>
}
/>
<MarketplaceDetailHero
integration={integration}
title={pageTitle}
subtitle={pageSubTitle}
tabs={tabs}
/>
{activeRoute === 'overview' ? (
<div className={centeredContentClass}>
<OverviewTab integration={integration} isInstalled={isInstalled} />
</div>
) : CustomPageComponent ? (
layout === 'constrained' ? (
<div className={centeredContentClass}>
<CustomPageComponent />
</div>
) : (
<CustomPageComponent />
)
) : null}
</>
)
}
@@ -0,0 +1,83 @@
import Link from 'next/link'
import type { ReactNode } from 'react'
import { Badge, cn, NavMenu, NavMenuItem } from 'ui'
import {
PageHeader,
PageHeaderDescription,
PageHeaderIcon,
PageHeaderMeta,
PageHeaderNavigationTabs,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns'
import { getMarketplaceSource, MarketplaceSourceBadge } from './Marketplace.constants'
import { IntegrationLogo } from '@/components/interfaces/Integrations/Integration/IntegrationLogo'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
interface MarketplaceDetailHeroProps {
integration: IntegrationDefinition
title: string
subtitle?: ReactNode
tabs: Array<{ label: string; href: string; active: boolean }>
}
interface BadgesComponentProps {
className?: string
source: ReturnType<typeof getMarketplaceSource>
integration: IntegrationDefinition
}
const BadgesComponent = ({ className, source, integration }: BadgesComponentProps) => {
return (
<div className={cn('flex items-center gap-2', className)}>
<MarketplaceSourceBadge source={source} />
{integration.status && <Badge variant="warning">{integration.status}</Badge>}
</div>
)
}
export const MarketplaceDetailHero = ({
integration,
title,
subtitle,
tabs,
}: MarketplaceDetailHeroProps) => {
const source = getMarketplaceSource(integration)
return (
<PageHeader size="full" className={cn('@container')}>
<PageHeaderMeta className="mx-auto w-full flex @xl:flex-col @xl:justify-start @xl:items-start gap-4">
<div className="mx-auto w-full flex items-center gap-2 @lg:gap-4">
<PageHeaderIcon>
<IntegrationLogo integration={integration} size="w-10 h-10 @lg:w-14 @lg:h-14" />
</PageHeaderIcon>
<PageHeaderSummary className="gap-y-0.5">
<div className="mb-1 flex flex-col flex-wrap @lg:flex-row @lg:items-center gap-2">
<PageHeaderTitle className="heading-title truncate">{title}</PageHeaderTitle>
<BadgesComponent
className="hidden @xl:flex"
source={source}
integration={integration}
/>
</div>
<PageHeaderDescription className="hidden @lg:block">{subtitle}</PageHeaderDescription>
</PageHeaderSummary>
</div>
<PageHeaderDescription className="@lg:hidden">{subtitle}</PageHeaderDescription>
<BadgesComponent className="flex @xl:hidden" source={source} integration={integration} />
</PageHeaderMeta>
{tabs.length > 0 && (
<PageHeaderNavigationTabs className="mx-auto w-full">
<NavMenu>
{tabs.map((tab) => (
<NavMenuItem key={tab.href} active={tab.active}>
<Link href={tab.href}>{tab.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</PageHeaderNavigationTabs>
)}
</PageHeader>
)
}
@@ -0,0 +1,91 @@
import { ArrowUpRight } from 'lucide-react'
import type { ReactNode } from 'react'
import { cn } from 'ui'
import { getMarketplaceType, getMarketplaceTypeLabel } from './Marketplace.constants'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
interface RailRowProps {
label: string
value: ReactNode
href?: string
mono?: boolean
}
const RailRow = ({ label, value, href, mono }: RailRowProps) => {
const valueCls = cn(
'flex items-center gap-1 text-sm',
href ? 'text-brand-link' : 'text-foreground',
mono && 'font-mono'
)
const content = (
<>
{value}
{href && <ArrowUpRight size={11} />}
</>
)
return (
<div className="flex flex-col gap-1">
<div className="text-xs text-foreground-lighter">{label}</div>
{href ? (
<a href={href} target="_blank" rel="noreferrer" className={valueCls}>
{content}
</a>
) : (
<div className={valueCls}>{content}</div>
)}
</div>
)
}
interface RailGroupProps {
title: string
children: ReactNode
}
const RailGroup = ({ title, children }: RailGroupProps) => (
<div className="flex flex-col gap-3 border-b pb-4 last:border-b-0">
<div className="font-mono text-[10px] uppercase tracking-widest text-foreground-lighter">
{title}
</div>
<div className="flex flex-col gap-3">{children}</div>
</div>
)
interface MarketplaceDetailRailProps {
integration: IntegrationDefinition
isInstalled: boolean
}
const tryHostname = (url: string | null | undefined) => {
if (!url) return undefined
try {
return new URL(url).hostname
} catch {
return undefined
}
}
export const MarketplaceDetailRail = ({ integration, isInstalled }: MarketplaceDetailRailProps) => {
const typeLabel = getMarketplaceTypeLabel(getMarketplaceType(integration))
const docsUrl = integration.docsUrl ?? undefined
const siteUrl = integration.siteUrl ?? undefined
const siteHost = tryHostname(siteUrl)
return (
<aside className="sticky top-6 flex flex-col gap-4 self-start text-sm">
<RailGroup title="About">
<RailRow label="Type" value={typeLabel} />
<RailRow label="Built by" value={integration.author?.name || 'Supabase'} />
{isInstalled && <RailRow label="Status" value="Installed" />}
</RailGroup>
{(docsUrl || siteUrl) && (
<RailGroup title="Links">
{docsUrl && <RailRow label="Documentation" value="Read the docs" href={docsUrl} />}
{siteUrl && siteHost && <RailRow label="Website" value={siteHost} href={siteUrl} />}
</RailGroup>
)}
</aside>
)
}
@@ -0,0 +1,46 @@
import { useParams } from 'common'
import Link from 'next/link'
import type { ReactNode } from 'react'
import {
Badge,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from 'ui'
interface MarketplaceDetailTopBarProps {
title: string
actions?: ReactNode
isInstalled?: boolean
}
export const MarketplaceDetailTopBar = ({
title,
isInstalled,
actions,
}: MarketplaceDetailTopBarProps) => {
const { ref } = useParams()
return (
<div className="sticky min-h-(--header-height) top-0 z-20 flex justify-between items-center border-b bg-dash-sidebar px-6 py-2.5 xl:px-10">
<div className="flex min-w-0 flex-1 items-center gap-4 [&_li]:text-xs">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/project/${ref}/integrations`}>Integrations</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="block min-w-0 truncate">{title}</BreadcrumbPage>
{isInstalled && <Badge variant="success">Installed</Badge>}
</BreadcrumbItem>
</BreadcrumbList>
</div>
<div className="flex shrink-0 items-center gap-2">{actions}</div>
</div>
)
}
@@ -0,0 +1,225 @@
import { ArrowRight, Pause, Play } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Badge, Button, cn } from 'ui'
import { IntegrationLogo } from '../Integration/IntegrationLogo'
import {
formatCategoryLabel,
getMarketplaceSource,
MarketplaceSourceBadge,
} from './Marketplace.constants'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const ROTATION_INTERVAL_MS = 7000
interface MarketplaceFeaturedHeroProps {
integrations: IntegrationDefinition[]
installedIds: string[]
categoryOptions: Array<{ slug: string; name: string }>
}
export const MarketplaceFeaturedHero = ({
integrations,
installedIds,
categoryOptions,
}: MarketplaceFeaturedHeroProps) => {
const { data: project } = useSelectedProjectQuery()
const [activeIndex, setActiveIndex] = useState(0)
const [isPaused, setIsPaused] = useState(false)
const hoveringRef = useRef(false)
const cardRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (integrations.length <= 1) return
if (isPaused) return
const interval = setInterval(() => {
if (hoveringRef.current) return
setActiveIndex((idx) => (idx + 1) % integrations.length)
}, ROTATION_INTERVAL_MS)
return () => clearInterval(interval)
}, [integrations.length, isPaused])
useEffect(() => {
const node = cardRef.current
if (!node) return
const enter = () => {
hoveringRef.current = true
}
const leave = () => {
hoveringRef.current = false
}
node.addEventListener('pointerenter', enter)
node.addEventListener('pointerleave', leave)
return () => {
node.removeEventListener('pointerenter', enter)
node.removeEventListener('pointerleave', leave)
}
}, [])
if (integrations.length === 0) return null
const active = integrations[Math.min(activeIndex, integrations.length - 1)]
const activeCategorySlug = active.categories?.[0]
const activeCategoryName = activeCategorySlug
? formatCategoryLabel(activeCategorySlug, categoryOptions)
: null
const isActiveInstalled = installedIds.includes(active.id)
const source = getMarketplaceSource(active)
const handleTabClick = (idx: number) => {
setActiveIndex(idx)
}
return (
<section>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm">Featured integrations</h2>
</div>
<Button
aria-label={isPaused ? 'Resume auto-rotation' : 'Pause auto-rotation'}
type="default"
size="tiny"
className="px-1.5"
icon={isPaused ? <Play size={10} /> : <Pause size={10} />}
onClick={() => setIsPaused((p) => !p)}
/>
</div>
<div ref={cardRef} className="overflow-hidden rounded-lg border bg-surface-100">
<Link
href={`/project/${project?.ref}/integrations/${active.id}/overview`}
className="grid gap-0 @3xl:grid-cols-[minmax(0,280px)_minmax(0,1fr)] hover:bg-selection/20 transition-colors"
>
<div className="relative hidden @3xl:block">
<FeaturedCover integration={active} categoryLabel={activeCategoryName} />
</div>
<div className="flex flex-col gap-4 p-5 @3xl:p-6">
<div className="flex items-start @lg:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="flex flex-wrap flex-col @lg:flex-row @lg:items-center gap-2">
<div className="flex items-center gap-2">
<IntegrationLogo integration={active} size="h-7 w-7" />
<span className="text-lg font-medium text-foreground">{active.name}</span>
</div>
<div className="flex items-center gap-2">
<MarketplaceSourceBadge source={source} />
{active.status && <Badge variant="warning">{active.status}</Badge>}
</div>
</div>
</div>
{isActiveInstalled && (
<Badge variant="success" className="mt-2 @lg:mt-0 px-1.5 py-0 text-[10px]">
Installed
</Badge>
)}
</div>
<div className="grow flex flex-col gap-2">
<p className="text-base text-foreground-light">{active.description}</p>
</div>
<div className="mt-1 flex items-center gap-4">
<Button
asChild
type="text"
className="p-0 pointer-events-none"
iconRight={<ArrowRight size={12} />}
size="tiny"
>
<Link href={`/project/${project?.ref}/integrations/${active.id}/overview`}>
{isActiveInstalled ? 'Manage integration' : 'View integration'}
</Link>
</Button>
</div>
</div>
</Link>
<div className="grid grid-cols-2 border-t @md:grid-cols-3">
{integrations.map((integration, idx) => {
const isActive = idx === activeIndex
const slug = integration.categories?.[0]
const categoryLabel = slug ? formatCategoryLabel(slug, categoryOptions) : null
return (
<button
key={integration.id}
type="button"
onClick={() => handleTabClick(idx)}
className={cn(
'group relative flex items-center gap-2.5 border-r p-3 text-left transition-colors last:border-r-0',
isActive ? 'bg-surface-200' : 'bg-transparent hover:bg-surface-200/60'
)}
aria-pressed={isActive}
>
<IntegrationLogo integration={integration} size="h-7 w-7" />
<div className="min-w-0 flex-1">
<div
className={cn(
'truncate text-sm',
isActive ? 'text-foreground' : 'text-foreground-light'
)}
>
{integration.name}
</div>
{categoryLabel && (
<div className="truncate text-xs text-foreground-lighter">{categoryLabel}</div>
)}
</div>
{isActive && (
<span
key={activeIndex}
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left animate-marketplace-featured-progress bg-brand"
style={{
animationDuration: `${ROTATION_INTERVAL_MS}ms`,
animationPlayState: isPaused ? 'paused' : 'running',
}}
/>
)}
</button>
)
})}
</div>
</div>
</section>
)
}
interface FeaturedCoverProps {
integration: IntegrationDefinition
categoryLabel: string | null
}
// Decorative cover panel on the left of the hero. Uses a subtle dotted
// gradient and renders the integration's own icon at a large scale so each
// partner stays visually distinct without bespoke artwork per listing.
const FeaturedCover = ({ integration, categoryLabel }: FeaturedCoverProps) => (
<div
key={integration.id}
className="relative h-full min-h-[200px] w-full overflow-hidden bg-linear-to-br from-surface-200 via-surface-100 to-surface-300"
>
<div
aria-hidden
className="absolute inset-0 opacity-60"
style={{
backgroundImage:
'radial-gradient(circle at 1px 1px, hsl(var(--border-default)/0.6) 1px, transparent 0)',
backgroundSize: '14px 14px',
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative flex h-24 w-24 items-center justify-center overflow-hidden rounded-2xl border bg-white shadow-sm">
{integration.icon({ className: 'h-16 w-16' })}
</div>
</div>
{categoryLabel && (
<span className="absolute bottom-3 left-3 text-[10px] font-medium uppercase tracking-wider text-foreground-lighter">
{categoryLabel}
</span>
)}
</div>
)
@@ -0,0 +1,237 @@
import { ChevronDown, LayoutGrid, List, Search } from 'lucide-react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import {
formatCategoryLabel,
getCategoryIcon,
INTEGRATION_TYPES,
MARKETPLACE_SOURCES,
type MarketplaceIntegrationType,
type MarketplaceSource,
} from './Marketplace.constants'
export type ViewMode = 'list' | 'grid'
interface MarketplaceFilterBarProps {
resultCount: number
search: string
onSearchChange: (value: string) => void
category: string | null
onCategoryChange: (value: string | null) => void
categoryOptions: Array<{ slug: string; name: string }>
categoryCounts?: Record<string, number>
type: MarketplaceIntegrationType | null
onTypeChange: (value: MarketplaceIntegrationType | null) => void
typeCounts?: Record<MarketplaceIntegrationType, number>
source: MarketplaceSource | null
onSourceChange: (value: MarketplaceSource | null) => void
sourceCounts?: Record<MarketplaceSource, number>
viewMode: ViewMode
onViewModeChange: (value: ViewMode) => void
hasActiveFilter: boolean
onClearFilters: () => void
}
const ALL = '__all__'
const triggerCls = 'inline-flex flex-row gap-2'
export const MarketplaceFilterBar = ({
resultCount,
search,
onSearchChange,
category,
onCategoryChange,
categoryOptions,
categoryCounts,
type,
onTypeChange,
typeCounts,
source,
onSourceChange,
sourceCounts,
viewMode,
onViewModeChange,
hasActiveFilter,
onClearFilters,
}: MarketplaceFilterBarProps) => {
const showClear = hasActiveFilter || search.length > 0
const categoryLabel = category ? formatCategoryLabel(category, categoryOptions) : 'All'
const typeLabel = type ? (INTEGRATION_TYPES.find((t) => t.key === type)?.label ?? type) : 'All'
const sourceLabel = source
? (MARKETPLACE_SOURCES.find((s) => s.key === source)?.label ?? source)
: 'All'
return (
<div
className={cn(
'sticky top-0 z-5 -mx-6 flex flex-wrap items-center gap-2 px-6 py-3 xl:-mx-10 xl:px-10',
'bg-dash-sidebar/95 backdrop-blur',
showClear ? 'border-b border-muted' : 'border-b border-transparent'
)}
>
<Input
value={search}
size="tiny"
onChange={(e) => onSearchChange(e.target.value)}
placeholder={`Search integration${resultCount === 1 ? '' : 's'}…`}
icon={<Search size={14} />}
containerClassName="w-full min-w-40 max-w-60 grow flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />} className={triggerCls}>
<span className="truncate">Category: {categoryLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={category ?? ALL}
onValueChange={(v) => onCategoryChange(v === ALL ? null : v)}
>
<DropdownMenuItem onClick={() => onCategoryChange(null)}>
All categories
</DropdownMenuItem>
<DropdownMenuSeparator />
{categoryOptions.map((c) => {
const Icon = getCategoryIcon(c.slug)
return (
<DropdownMenuItem
key={c.slug}
onClick={() => onCategoryChange(c.slug)}
className="flex items-center gap-2"
>
<Icon size={13} className="text-foreground-lighter" />
<span className="flex-1">{c.name}</span>
{categoryCounts?.[c.slug] !== undefined && (
<span className="font-mono text-xs text-foreground-lighter">
{categoryCounts[c.slug]}
</span>
)}
</DropdownMenuItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />} className={triggerCls}>
<span className="truncate">Type: {typeLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={type ?? ALL}
onValueChange={(v) =>
onTypeChange(v === ALL ? null : (v as MarketplaceIntegrationType))
}
>
<DropdownMenuItem onClick={() => onTypeChange(null)}>All Types</DropdownMenuItem>
<DropdownMenuSeparator />
{INTEGRATION_TYPES.map(({ key, label, icon: Icon }) => (
<DropdownMenuItem
key={key}
onClick={() => onTypeChange(key)}
className="flex items-center gap-2"
>
<Icon size={13} className="text-foreground-lighter" />
<span className="flex-1">{label}</span>
{typeCounts?.[key] !== undefined && (
<span className="font-mono text-xs text-foreground-lighter">
{typeCounts[key]}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />} className={triggerCls}>
<span className="truncate">Source: {sourceLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={source ?? ALL}
onValueChange={(v) => onSourceChange(v === ALL ? null : (v as MarketplaceSource))}
>
<DropdownMenuItem onClick={() => onSourceChange(null)}>All sources</DropdownMenuItem>
<DropdownMenuSeparator />
{MARKETPLACE_SOURCES.map(({ key, label, icon: Icon }) => (
<DropdownMenuItem
key={key}
onClick={() => onSourceChange(key)}
className="flex items-center gap-2"
>
<Icon size={13} className="text-foreground-lighter" />
<span className="flex-1">{label}</span>
{sourceCounts?.[key] !== undefined && (
<span className="font-mono text-xs text-foreground-lighter">
{sourceCounts[key]}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{showClear && (
<button
type="button"
onClick={onClearFilters}
className="px-1 text-xs text-foreground-light underline-offset-2 hover:underline"
>
Clear all
</button>
)}
<div className="ml-auto flex rounded-md border">
<button
type="button"
aria-label="Grid view"
onClick={() => onViewModeChange('grid')}
className={cn(
'border-r px-2 py-1.5 rounded-l-md',
'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background',
viewMode === 'grid'
? 'bg-surface-200 text-foreground'
: 'text-foreground-light hover:bg-surface-100'
)}
>
<LayoutGrid size={13} />
</button>
<button
type="button"
aria-label="List view"
onClick={() => onViewModeChange('list')}
className={cn(
'px-2 py-1.5 rounded-r-md',
'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background',
viewMode === 'list'
? 'bg-surface-200 text-foreground'
: 'text-foreground-light hover:bg-surface-100'
)}
>
<List size={13} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,291 @@
import { useQuery } from '@tanstack/react-query'
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'
import { useMemo } from 'react'
import { Button, Card, ShadowScrollArea, Table, TableBody, TableHeader } from 'ui'
import {
EmptyStatePresentational,
PageContainer,
PageHeader,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns'
import {
EXCLUDED_CATEGORY_SLUGS,
FEATURED_INTEGRATION_IDS,
formatCategoryLabel,
getMarketplaceSource,
getMarketplaceType,
INTEGRATION_TYPES,
MARKETPLACE_SOURCES,
type MarketplaceIntegrationType,
type MarketplaceSource,
} from './Marketplace.constants'
import { MarketplaceCard } from './MarketplaceCard'
import { MarketplaceFeaturedHero } from './MarketplaceFeaturedHero'
import { MarketplaceFilterBar, type ViewMode } from './MarketplaceFilterBar'
import { MarketplaceListHeader, MarketplaceListRow } from './MarketplaceListRow'
import { IntegrationLoadingCard } from '@/components/interfaces/Integrations/Landing/IntegrationCard'
import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { marketplaceCategoriesQueryOptions } from '@/data/marketplace/integration-categories-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { DOCS_URL } from '@/lib/constants'
const MARKETPLACE_VIEW_MODE_STORAGE_KEY = 'supabase.marketplace.viewMode'
export const MarketplaceIndex = () => {
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault('').withOptions({ clearOnDefault: true })
)
const [category, setCategory] = useQueryState(
'category',
parseAsString.withOptions({ clearOnDefault: true })
)
const [type, setType] = useQueryState(
'type',
parseAsStringEnum<MarketplaceIntegrationType>([
'oauth',
'postgres_extension',
'template',
'wrapper',
]).withOptions({ clearOnDefault: true })
)
const [source, setSource] = useQueryState(
'source',
parseAsStringEnum<MarketplaceSource>(['Official', 'Partner', 'Community']).withOptions({
clearOnDefault: true,
})
)
const [viewMode, setViewMode] = useLocalStorageQuery<ViewMode>(
MARKETPLACE_VIEW_MODE_STORAGE_KEY,
'grid'
)
const {
data: availableIntegrations,
error,
isPending: isLoadingAvailable,
isError,
isSuccess: isSuccessAvailable,
} = useAvailableIntegrations()
const {
installedIntegrations,
isLoading: isLoadingInstalled,
isSuccess: isSuccessInstalled,
} = useInstalledIntegrations()
const installedIds = installedIntegrations.map((i) => i.id)
const isLoading = isLoadingAvailable || isLoadingInstalled
const isSuccess = isSuccessAvailable && isSuccessInstalled
const { data: marketplaceCategories = [] } = useQuery(marketplaceCategoriesQueryOptions())
const categoryOptions = useMemo(
() =>
marketplaceCategories
.filter(
(c): c is { slug: string; name: string } & typeof c =>
!!c.slug && !!c.name && !EXCLUDED_CATEGORY_SLUGS.has(c.slug)
)
.map((c) => ({ slug: c.slug, name: c.name })),
[marketplaceCategories]
)
const typeCounts = useMemo(() => {
const all = availableIntegrations ?? []
return Object.fromEntries(
INTEGRATION_TYPES.map(({ key }) => [
key,
all.filter((i) => getMarketplaceType(i) === key).length,
])
) as Record<MarketplaceIntegrationType, number>
}, [availableIntegrations])
const sourceCounts = useMemo(() => {
const all = availableIntegrations ?? []
return Object.fromEntries(
MARKETPLACE_SOURCES.map(({ key }) => [
key,
all.filter((i) => getMarketplaceSource(i) === key).length,
])
) as Record<MarketplaceSource, number>
}, [availableIntegrations])
const categoryCounts = useMemo(() => {
const all = availableIntegrations ?? []
const counts: Record<string, number> = {}
for (const { slug } of categoryOptions) {
counts[slug] = all.filter((i) => i.categories?.includes(slug)).length
}
return counts
}, [availableIntegrations, categoryOptions])
const hasActiveFilter = !!(category || type || source)
const hasSearchOrFilter = hasActiveFilter || search.length > 0
const filtered = useMemo(() => {
let result = availableIntegrations ?? []
if (category) {
result = result.filter((i) => i.categories?.includes(category))
}
if (type) {
result = result.filter((i) => getMarketplaceType(i) === type)
}
if (source) {
result = result.filter((i) => getMarketplaceSource(i) === source)
}
if (search.length > 0) {
const needle = search.toLowerCase()
result = result.filter((i) => i.name.toLowerCase().includes(needle))
}
return [...result].sort((a, b) => {
const aInstalled = installedIds.includes(a.id)
const bInstalled = installedIds.includes(b.id)
if (aInstalled && !bInstalled) return -1
if (!aInstalled && bInstalled) return 1
return a.name.localeCompare(b.name)
})
}, [availableIntegrations, category, type, source, search, installedIds])
const featured = useMemo(() => {
if (hasSearchOrFilter) return []
const byId = new Map((availableIntegrations ?? []).map((i) => [i.id, i]))
return FEATURED_INTEGRATION_IDS.map((id) => byId.get(id)).filter(
(i): i is NonNullable<typeof i> => !!i
)
}, [availableIntegrations, hasSearchOrFilter])
const clearAll = () => {
setCategory(null)
setType(null)
setSource(null)
setSearch('')
}
const activeFilters = [category, type, source].filter(Boolean)
const pageTitle = useMemo(() => {
if (activeFilters.length !== 1) return 'Integrations Marketplace'
if (category)
return `Integrations Marketplace: ${formatCategoryLabel(category, categoryOptions)}`
if (type)
return `Integrations Marketplace: ${INTEGRATION_TYPES.find((t) => t.key === type)?.label ?? type}s`
if (source)
return `Integrations Marketplace: ${MARKETPLACE_SOURCES.find((s) => s.key === source)?.label ?? source}`
return 'Integrations Marketplace'
}, [activeFilters.length, category, type, source, categoryOptions])
return (
<>
<PageHeader size="large">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>{pageTitle}</PageHeaderTitle>
<PageHeaderDescription>
Explore native and third-party integrations to add functionality to your Supabase
project.
</PageHeaderDescription>
</PageHeaderSummary>
<div className="flex shrink-0 items-center gap-2">
<DocsButton href={`${DOCS_URL}/guides/integrations/supabase-marketplace`} />
</div>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large" className="flex flex-col gap-4 py-6">
{isLoading && (
<div className="grid gap-4 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, idx) => (
<IntegrationLoadingCard key={`marketplace-loading-${idx}`} />
))}
</div>
)}
{isError && (
<AlertError subject="Failed to retrieve available integrations" error={error} />
)}
{isSuccess && (
<>
{featured.length > 0 && (
<MarketplaceFeaturedHero
integrations={featured}
installedIds={installedIds}
categoryOptions={categoryOptions}
/>
)}
<MarketplaceFilterBar
resultCount={filtered.length}
search={search}
onSearchChange={(v) => setSearch(v)}
category={category}
onCategoryChange={(v) => setCategory(v)}
categoryOptions={categoryOptions}
categoryCounts={categoryCounts}
type={type}
onTypeChange={(v) => setType(v)}
typeCounts={typeCounts}
source={source}
onSourceChange={(v) => setSource(v)}
sourceCounts={sourceCounts}
viewMode={viewMode}
onViewModeChange={(v) => setViewMode(v)}
hasActiveFilter={hasActiveFilter}
onClearFilters={clearAll}
/>
{filtered.length === 0 && (
<EmptyStatePresentational title="No results found">
<Button type="default" onClick={clearAll}>
Clear filters
</Button>
</EmptyStatePresentational>
)}
{filtered.length > 0 &&
(viewMode === 'grid' ? (
<div className="grid @lg:grid-cols-2 gap-3 @4xl:grid-cols-3">
{filtered.map((integration) => (
<MarketplaceCard
key={integration.id}
integration={integration}
isInstalled={installedIds.includes(integration.id)}
/>
))}
</div>
) : (
<Card>
<ShadowScrollArea>
<Table>
<TableHeader>
<MarketplaceListHeader />
</TableHeader>
<TableBody>
{filtered.map((integration) => (
<MarketplaceListRow
key={integration.id}
integration={integration}
isInstalled={installedIds.includes(integration.id)}
/>
))}
</TableBody>
</Table>
</ShadowScrollArea>
</Card>
))}
</>
)}
</PageContainer>
</>
)
}
@@ -0,0 +1,95 @@
import Link from 'next/link'
import { Badge, TableCell, TableHead, TableRow } from 'ui'
import { IntegrationLogo } from '../Integration/IntegrationLogo'
import {
formatCategoryLabel,
getMarketplaceSource,
getMarketplaceType,
getMarketplaceTypeLabel,
MarketplaceSourceBadge,
} from './Marketplace.constants'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface MarketplaceListRowProps {
integration: IntegrationDefinition
isInstalled: boolean
}
const HIDE_BELOW_XL = 'hidden @3xl:table-cell'
const HIDE_BELOW_4XL = 'hidden @4xl:table-cell'
export const MarketplaceListRow = ({ integration, isInstalled }: MarketplaceListRowProps) => {
const { data: project } = useSelectedProjectQuery()
const source = getMarketplaceSource(integration)
const installMechanism = getMarketplaceTypeLabel(getMarketplaceType(integration))
const href = `/project/${project?.ref}/integrations/${integration.id}/overview`
return (
<TableRow className="group relative transition-colors hover:bg-surface-100 [&>td]:py-2 @lg:[&>td]:py-2.5">
<TableCell className="w-10 pr-0 @lg:w-12">
<IntegrationLogo integration={integration} size="h-7 w-7 @lg:h-8 @lg:w-8" />
</TableCell>
<TableCell>
<Link
href={href}
className="block w-full hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background rounded-md"
>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<div className="relative z-10 text-sm font-medium @lg:text-sm after:absolute after:-inset-y-2 after:left-0 after:right-[calc(100%+6px)] after:content-[''] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground">
{integration.name}
</div>
<div className="relative z-10">
<MarketplaceSourceBadge source={source} />
{integration.status && (
<Badge variant="warning" className="capitalize">
{integration.status}
</Badge>
)}
</div>
</div>
{integration.description && (
<p className="mt-0.5 line-clamp-1 max-w-[600px] text-xs text-foreground-light">
{integration.description}
</p>
)}
</Link>
</TableCell>
<TableCell className={`w-28 text-xs text-foreground-lighter ${HIDE_BELOW_XL}`}>
{formatCategoryLabel(integration.categories?.[0]) || '—'}
</TableCell>
<TableCell className={`w-32 font-mono text-xs text-foreground-lighter ${HIDE_BELOW_4XL}`}>
{installMechanism}
</TableCell>
<TableCell className={`w-40 text-foreground-lighter ${HIDE_BELOW_4XL}`}>
{integration.author?.name || '—'}
</TableCell>
<TableCell className="w-24">
{isInstalled ? <Badge variant="success">Installed</Badge> : null}
</TableCell>
</TableRow>
)
}
interface MarketplaceListHeaderProps {
integrationsLabel?: string
}
export const MarketplaceListHeader = ({
integrationsLabel = 'Integration',
}: MarketplaceListHeaderProps) => (
<TableRow>
<TableHead className="w-10 pr-0 @lg:w-12" />
<TableHead>{integrationsLabel}</TableHead>
<TableHead className={`w-28 ${HIDE_BELOW_XL}`}>Category</TableHead>
<TableHead className={`w-40 ${HIDE_BELOW_4XL}`}>Type</TableHead>
<TableHead className={`w-40 ${HIDE_BELOW_4XL}`}>Built by</TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
)
@@ -0,0 +1,119 @@
import { useParams } from 'common'
import { ArrowUpRight, BookOpen, LayoutGrid, PlusSquare } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { cn } from 'ui'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { DOCS_URL } from '@/lib/constants'
const sectionLabelCls =
'px-2 pt-4 pb-1.5 font-mono text-xs uppercase tracking-wider text-foreground-lighter'
interface SidebarLinkProps {
href: string
active?: boolean
icon?: React.ReactNode
label: React.ReactNode
count?: number | string
className?: string
}
const SidebarLink = ({ href, active, icon, label, count, className }: SidebarLinkProps) => (
<Link
href={href}
className={cn(
'flex items-center justify-between rounded px-2 py-1 text-sm',
'text-foreground-light hover:bg-surface-200 hover:text-foreground',
active && 'bg-surface-200 text-foreground',
className
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-foreground-lighter">{icon}</span>
{label}
</span>
{count !== undefined && (
<span className="font-mono text-xs text-foreground-lighter">{count}</span>
)}
</Link>
)
const HELP_LINKS: Array<{ icon: React.ReactNode; label: string; href: string }> = [
{
icon: <BookOpen className="text-foreground-lighter" size={13} />,
label: 'Integrations docs',
href: `${DOCS_URL}/guides/integrations`,
},
{
icon: <PlusSquare className="text-foreground-lighter" size={13} />,
label: 'Build an integration',
href: `${DOCS_URL}/guides/integrations/build-a-supabase-oauth-integration`,
},
]
export const MarketplaceSidebar = () => {
const router = useRouter()
const { ref } = useParams()
const { installedIntegrations } = useInstalledIntegrations()
const baseHref = `/project/${ref}/integrations`
const isDiscoverActive =
router.pathname === '/project/[ref]/integrations' && !router.query.type && !router.query.source
const activeIntegrationId = typeof router.query.id === 'string' ? router.query.id : undefined
return (
<aside className="grow flex h-full w-full flex-col gap-y-0.5 overflow-y-auto p-4 text-sm">
<SidebarLink
href={baseHref}
active={isDiscoverActive}
icon={<LayoutGrid size={13} />}
label="Marketplace"
/>
{installedIntegrations.length > 0 && (
<>
<div className={sectionLabelCls}>Installed · {installedIntegrations.length}</div>
{installedIntegrations.map((integration) => {
const isActive = activeIntegrationId === integration.id
return (
<Link
key={integration.id}
href={`${baseHref}/${integration.id}/overview`}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded px-2 py-1 text-sm',
'text-foreground-light hover:bg-surface-200 hover:text-foreground',
isActive && 'bg-surface-200 text-foreground'
)}
>
<span className="relative flex h-[18px] w-[18px] shrink-0 items-center justify-center overflow-hidden rounded-[4px] border bg-white">
{integration.icon({ className: 'p-0.5' })}
</span>
<span className="truncate">{integration.name}</span>
</Link>
)
})}
</>
)}
<div className={sectionLabelCls}>Resources</div>
{HELP_LINKS.map(({ icon, label, href }) => (
<a
key={label}
href={href}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded px-2 py-1 text-sm text-foreground-light hover:bg-surface-200 hover:text-foreground"
>
<span className="flex items-center gap-2">
{icon}
{label}
</span>
<ArrowUpRight size={11} className="opacity-50" />
</a>
))}
</aside>
)
}
@@ -0,0 +1,26 @@
import { MarketplaceDetailRail } from './MarketplaceDetailRail'
import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold'
import { FilesViewer } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer'
import { MarkdownContent } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
interface OverviewTabProps {
integration: IntegrationDefinition
isInstalled: boolean
}
export const OverviewTab = ({ integration, isInstalled }: OverviewTabProps) => {
const { content, files = [] } = integration
return (
<ConstrainedIntegrationTabScaffold>
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 items-start gap-10 @4xl:grid-cols-[minmax(0,760px)_240px]">
<div className="min-w-0">
{files.length > 0 && <FilesViewer files={files} />}
<MarkdownContent integrationId={integration.id} content={content} />
</div>
<MarketplaceDetailRail integration={integration} isInstalled={isInstalled} />
</div>
</ConstrainedIntegrationTabScaffold>
)
}
@@ -1,10 +1,11 @@
import { useFlag, useParams } from 'common'
import { useParams } from 'common'
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -39,7 +40,7 @@ const QueuesAdmonition = () => {
export const QueuesOverviewTab = () => {
const { data: project } = useSelectedProjectQuery()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
@@ -10,6 +10,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold'
import { DocsButton } from '@/components/ui/DocsButton'
import { FormHeader } from '@/components/ui/Forms/FormHeader'
import {
@@ -193,7 +194,7 @@ export const QueuesSettings = () => {
return (
<>
<div className="w-full flex flex-col gap-y-4 p-10">
<ConstrainedIntegrationTabScaffold className="flex flex-col gap-y-4">
<FormHeader
className="mb-0"
title="Settings"
@@ -351,7 +352,7 @@ export const QueuesSettings = () => {
</FormPanelContainer>
</form>
</Form>
</div>
</ConstrainedIntegrationTabScaffold>
<ConfirmationModal
visible={rlsConfirmModalOpen}
@@ -3,6 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { DeleteHookModal } from '@/components/interfaces/Database/Hooks/DeleteHookModal'
import { EditHookPanel } from '@/components/interfaces/Database/Hooks/EditHookPanel'
import { HooksList } from '@/components/interfaces/Database/Hooks/HooksList/HooksList'
import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold'
import { NoPermission } from '@/components/ui/NoPermission'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -13,14 +14,18 @@ export const WebhooksListTab = () => {
)
if (isPermissionsLoaded && !canReadWebhooks) {
return <NoPermission isFullPage resourceText="view database webhooks" />
return (
<ConstrainedIntegrationTabScaffold>
<NoPermission isFullPage resourceText="view database webhooks" />
</ConstrainedIntegrationTabScaffold>
)
}
return (
<div className="p-10">
<ConstrainedIntegrationTabScaffold>
<HooksList />
<EditHookPanel />
<DeleteHookModal />
</div>
</ConstrainedIntegrationTabScaffold>
)
}
@@ -1,11 +1,12 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { useParams } from 'common'
import { toast } from 'sonner'
import { Admonition } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import NoPermission from '@/components/ui/NoPermission'
import { useHooksEnableMutation } from '@/data/database/hooks-enable-mutation'
@@ -17,7 +18,7 @@ export const WebhooksOverviewTab = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const {
data: schemas,
@@ -1,5 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { useParams } from 'common'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
@@ -13,6 +13,7 @@ import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
import { CreateWrapperSheet } from './CreateWrapperSheet'
import { WRAPPERS } from './Wrappers.constants'
import { WrapperTable } from './WrapperTable'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
@@ -157,7 +158,7 @@ const AddNewWrapperCTA = () => {
export const WrapperOverviewTab = () => {
const { id } = useParams()
const { data: project } = useSelectedProjectQuery()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const { data: integrations = [] } = useAvailableIntegrations()
const integration = integrations.find((i) => i.id === id)
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag } from 'common'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -40,6 +39,7 @@ import {
isUninstalling,
} from './stripe-sync-status'
import { StripeSyncChangesCard } from './StripeSyncChangesCard'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
import { useStripeSyncInstallMutation } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
import { useStripeSyncUninstallMutation } from '@/data/database-integrations/stripe/stripe-sync-uninstall-mutation'
@@ -56,7 +56,7 @@ export const StripeSyncEngineOverviewTab = () => {
const track = useTrack()
const hasTrackedInstallFailed = useRef(false)
const { data: project } = useSelectedProjectQuery()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const [showUninstallModal, setShowUninstallModal] = useState(false)
const [shouldShowInstallSheet, setShouldShowInstallSheet] = useState(false)
@@ -4,7 +4,6 @@ import { BadgeCheck, RefreshCwIcon } from 'lucide-react'
import Link from 'next/link'
import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui'
import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionContent,
@@ -15,6 +14,7 @@ import {
} from 'ui-patterns/PageSection'
import { isInstalled, isSyncRunning, isUninstalling } from './stripe-sync-status'
import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold'
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
export const StripeSyncSettingsPage = () => {
@@ -30,17 +30,17 @@ export const StripeSyncSettingsPage = () => {
if (!installed || uninstalling) {
return (
<PageContainer className="mx-0">
<ConstrainedIntegrationTabScaffold>
<PageSection>
<Admonition type="default" description="Stripe Sync Engine is not installed." />
</PageSection>
</PageContainer>
</ConstrainedIntegrationTabScaffold>
)
}
return (
<PageContainer className="mx-0">
<PageSection>
<ConstrainedIntegrationTabScaffold>
<PageSection className="py-0!">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Manage Stripe data</PageSectionTitle>
@@ -50,7 +50,7 @@ export const StripeSyncSettingsPage = () => {
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card className="max-w-4xl">
<Card>
<CardHeader>
<CardTitle className="text-foreground-lighter">
{!syncState ? (
@@ -121,6 +121,6 @@ export const StripeSyncSettingsPage = () => {
</Card>
</PageSectionContent>
</PageSection>
</PageContainer>
</ConstrainedIntegrationTabScaffold>
)
}
@@ -10,7 +10,7 @@ export const ServiceList = () => {
const { data: project, isPending: isLoading } = useSelectedProjectQuery()
return (
<ScaffoldSection isFullWidth id="api-settings" className="gap-6">
<ScaffoldSection isFullWidth id="api-settings" className="gap-6 py-0!">
{!isLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? (
<Alert variant="destructive">
<AlertCircle size={16} />
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { IS_PLATFORM, useFeatureFlags, useFlag, useParams } from 'common'
import { IS_PLATFORM, useFeatureFlags, useParams } from 'common'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { Menu, Separator } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { ProjectLayout } from '@/components/layouts/ProjectLayout'
import AlertError from '@/components/ui/AlertError'
@@ -46,7 +47,7 @@ const IntegrationCategoriesMenu = ({ page }: { page: string }) => {
const router = useRouter()
const { ref } = useParams()
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const urlParams = new URLSearchParams(router.asPath.split('?')[1] || '')
const categoryParam = urlParams.get('category')
@@ -0,0 +1,13 @@
import type { PropsWithChildren } from 'react'
import { ProjectIntegrationsLayout } from './ProjectIntegrationsLayout'
import { ProjectMarketplaceLayout } from './ProjectMarketplaceLayout'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const ProjectIntegrationsLayoutDispatch = ({ children }: PropsWithChildren) => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return <ProjectMarketplaceLayout>{children}</ProjectMarketplaceLayout>
}
return <ProjectIntegrationsLayout>{children}</ProjectIntegrationsLayout>
}
@@ -0,0 +1,19 @@
import { PropsWithChildren } from 'react'
import { MarketplaceSidebar } from '@/components/interfaces/Integrations/Marketplace/MarketplaceSidebar'
import { ProjectLayout } from '@/components/layouts/ProjectLayout'
import { withAuth } from '@/hooks/misc/withAuth'
export const ProjectMarketplaceLayout = withAuth(({ children }: PropsWithChildren) => {
return (
<ProjectLayout
product="Integrations"
browserTitle={{ section: 'Integrations' }}
isBlocking={false}
productMenu={<MarketplaceSidebar />}
productMenuClassName="p-0"
>
{children}
</ProjectLayout>
)
})
@@ -16,7 +16,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayout } from '@/components/layouts/ProjectIntegrationsLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import type { NextPageWithLayout } from '@/types'
const IntegrationPage: NextPageWithLayout = () => {
@@ -98,7 +98,7 @@ const IntegrationPage: NextPageWithLayout = () => {
IntegrationPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayout>{page}</ProjectIntegrationsLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
</DefaultLayout>
)
@@ -1,246 +1,19 @@
import { useFlag, useParams } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo } from 'react'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
NavMenu,
NavMenuItem,
} from 'ui'
import {
Admonition,
PageContainer,
PageHeader,
PageHeaderBreadcrumb,
PageHeaderDescription,
PageHeaderIcon,
PageHeaderMeta,
PageHeaderNavigationTabs,
PageHeaderSummary,
PageHeaderTitle,
PageSection,
PageSectionContent,
} from 'ui-patterns'
import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { InstallIntegrationSheet } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallIntegrationSheet'
import { InstallOAuthIntegrationButton } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton'
import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import LegacyIntegrationPage from '@/components/interfaces/Integrations/Integration/LegacyIntegrationPage'
import { MarketplaceDetail } from '@/components/interfaces/Integrations/Marketplace/MarketplaceDetail'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayout } from '@/components/layouts/ProjectIntegrationsLayout'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import type { NextPageWithLayout } from '@/types'
type NavigationItem = { label: string; href: string; active?: boolean }
const IntegrationPage: NextPageWithLayout = () => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const { ref, id, pageId, childId } = useParams()
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: allIntegrations, isPending: isAvailableIntegrationsLoading } =
useAvailableIntegrations()
const { installedIntegrations, isLoading: isInstalledIntegrationsLoading } =
useInstalledIntegrations()
// everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net
const integration = useMemo(() => allIntegrations.find((i) => i.id === id), [allIntegrations, id])
const installation = useMemo(
() => installedIntegrations.find((inst) => inst.id === id),
[installedIntegrations, id]
)
const installableExtensions = (extensions ?? []).filter((ext) =>
(integration?.requiredExtensions ?? []).includes(ext.name)
)
const extensionsInstalled = installableExtensions.every((x) => x.installed_version)
// [Joshen] installedIntegrations doesn't return wrappers unless there's a wrapper created
const isInstalled =
!!integration && (!!installation || (integration.type === 'wrapper' && extensionsInstalled))
// Get the corresponding component dynamically
const Component = useMemo(
() => integration?.navigate({ id, pageId, childId }),
[integration, id, pageId, childId]
)
// Create navigation items from integration navigation
const navigationItems: NavigationItem[] = useMemo(() => {
if (!integration?.navigation) return []
// Only show navigation if the integration is installed, or if we're on the overview page
const showNavigation = isInstalled || pageId === 'overview'
if (!showNavigation) return []
const availableTabs = isInstalled
? integration.navigation
: integration.navigation.filter((tab) => tab.route === 'overview')
return availableTabs.map((nav) => ({
label: nav.label,
href: `/project/${ref}/integrations/${id}/${nav.route}`,
active: pageId === nav.route,
}))
}, [integration, pageId, isInstalled, ref, id])
useEffect(() => {
// if the integration is not installed, redirect to the overview page
if (
router &&
router?.isReady &&
!isInstalledIntegrationsLoading &&
!isInstalled &&
pageId !== 'overview'
) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
}, [isInstalled, isInstalledIntegrationsLoading, pageId, router, ref, id])
// Determine page title, icon, and subtitle based on state
const pageTitle = integration?.name || 'Integration not found'
const pageSubTitle =
integration?.description || 'If you think this is an error, please contact support'
// Get integration icon and subtitle
const pageIcon = integration ? (
<div className="shrink-0 w-14 h-14 relative bg-white border rounded-md flex items-center justify-center">
{integration.icon()}
</div>
) : null
// Determine content based on state
const content = useMemo(() => {
if (!router?.isReady || isInstalledIntegrationsLoading || isAvailableIntegrationsLoading) {
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
<GenericSkeletonLoader />
</PageSectionContent>
</PageSection>
</PageContainer>
)
} else if (!Component || !id || !integration) {
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
<Admonition type="warning" title="This integration is not currently available">
Please try again later or contact support if the problem persists.
</Admonition>
</PageSectionContent>
</PageSection>
</PageContainer>
)
} else {
return <Component />
}
}, [
router?.isReady,
isInstalledIntegrationsLoading,
isAvailableIntegrationsLoading,
id,
integration,
Component,
])
if (!router?.isReady) {
return null
}
if (!integrationsWrappers && id?.endsWith('_wrapper')) {
return <UnknownInterface urlBack={`/project/${ref}/integrations`} />
}
return (
<>
<PageHeader size="full">
<PageHeaderBreadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/project/${ref}/integrations`}>Integrations</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{integration?.name || 'Integration not found'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageHeaderBreadcrumb>
{isAvailableIntegrationsLoading ? (
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>
<ShimmeringLoader className="w-64 py-4" />
</PageHeaderTitle>
<PageHeaderDescription>
<ShimmeringLoader />
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
) : (
<PageHeaderMeta>
{pageIcon && <PageHeaderIcon>{pageIcon}</PageHeaderIcon>}
<PageHeaderSummary className="truncate gap-y-0.5">
<PageHeaderTitle>{pageTitle}</PageHeaderTitle>
<PageHeaderDescription className="truncate">{pageSubTitle}</PageHeaderDescription>
</PageHeaderSummary>
{integration?.type === 'oauth' ? (
<InstallOAuthIntegrationButton integration={integration} />
) : isMarketplaceEnabled && !!integration && !isInstalled ? (
<InstallIntegrationSheet integration={integration} />
) : isMarketplaceEnabled && isInstalled ? (
<Button disabled type="outline">
Installed
</Button>
) : null}
</PageHeaderMeta>
)}
{navigationItems.length > 0 && (
<PageHeaderNavigationTabs>
<NavMenu>
{navigationItems.map((nav) => (
<NavMenuItem key={nav.href} active={nav.active ?? false}>
<Link href={nav.href}>{nav.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</PageHeaderNavigationTabs>
)}
</PageHeader>
{content}
</>
)
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <MarketplaceDetail />
return <LegacyIntegrationPage />
}
IntegrationPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayout>{page}</ProjectIntegrationsLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
</DefaultLayout>
)
@@ -6,7 +6,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayout } from '@/components/layouts/ProjectIntegrationsLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import type { NextPageWithLayout } from '@/types'
const IntegrationPage: NextPageWithLayout = () => {
@@ -33,7 +33,7 @@ const IntegrationPage: NextPageWithLayout = () => {
IntegrationPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayout>{page}</ProjectIntegrationsLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
</DefaultLayout>
)
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { IS_PLATFORM, useFeatureFlags, useFlag } from 'common'
import { IS_PLATFORM, useFeatureFlags } from 'common'
import { Database } from 'common/marketplace.types'
import { Search } from 'lucide-react'
import { useRouter } from 'next/router'
@@ -18,6 +18,7 @@ import {
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent, PageSectionMeta } from 'ui-patterns/PageSection'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
IntegrationCard,
IntegrationLoadingCard,
@@ -25,8 +26,9 @@ import {
import { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { MarketplaceIndex } from '@/components/interfaces/Integrations/Marketplace/MarketplaceIndex'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayout } from '@/components/layouts/ProjectIntegrationsLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { NoSearchResults } from '@/components/ui/NoSearchResults'
@@ -205,8 +207,14 @@ function useFeaturedIntegratios(
}
const IntegrationsPage: NextPageWithLayout = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <MarketplaceIndex />
return <LegacyIntegrationsPage />
}
const LegacyIntegrationsPage = () => {
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault('').withOptions({ clearOnDefault: true })
@@ -357,7 +365,7 @@ const IntegrationsPage: NextPageWithLayout = () => {
IntegrationsPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayout>{page}</ProjectIntegrationsLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
</DefaultLayout>
)
Whitespace-only changes.
Whitespace-only changes.
+10
View File
@@ -46,6 +46,7 @@
--animate-badge-pulse: badge-pulse 3s ease-in-out infinite;
--animate-chevron-up: chevron-up 2s ease-in-out infinite;
--animate-sway: sway 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-marketplace-featured-progress: marketplace-featured-progress linear forwards;
}
@keyframes shimmer {
@@ -108,6 +109,15 @@
}
}
@keyframes marketplace-featured-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
:root {
--chart-1: var(--brand-default);
--chart-2: 173 58% 39%;
@@ -27,6 +27,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
UI_PREVIEW_JIT_DB_ACCESS: 'supabase-ui-jit-db-access',
UI_PREVIEW_RLS_TESTER: 'supabase-ui-rls-tester',
UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace',
NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge',
TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge',
@@ -157,6 +158,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,