mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 --> [](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:
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>
|
||||
)
|
||||
+18
-7
@@ -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>
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
+46
@@ -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>
|
||||
)
|
||||
}
|
||||
+225
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+7
-7
@@ -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.
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user