diff --git a/apps/docs/.env.development b/apps/docs/.env.development index 56120419134..d10a8699d76 100644 --- a/apps/docs/.env.development +++ b/apps/docs/.env.development @@ -7,16 +7,20 @@ NEXT_PUBLIC_BASE_PATH="/docs" # Whether to enable features available only on Supabase's own hosted services # Setting this to true requires certain secret keys -NEXT_PUBLIC_IS_PLATFORM="false" +NEXT_PUBLIC_IS_PLATFORM="true" # Base URL for the hosted MCP server. Overrides the DEFAULT_MCP_URL_PLATFORM # fallback in @ui-patterns/McpUrlBuilder. NEXT_PUBLIC_MCP_URL="http://localhost:8080/mcp" -# Supabase project containing integration information +# Supabase project containing integration information (legacy) NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co" NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M" +# Supabase project backing the Partner Catalog / Dashboard Integrations Marketplace +NEXT_PUBLIC_MARKETPLACE_API_URL="https://fgxbxpvumhvzrhqngsyu.supabase.co" +NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6" + # Supabase project containing docs content information -NEXT_PUBLIC_SUPABASE_URL="https://xguihxuzqibwxjnimxev.supabase.co" -NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTgzNzc1MTgsImV4cCI6MjAzMzk1MzUxOH0.aIqjQ9V7djMxYit-DT1fYNV_VWMHSqldh_18XfX2_BE" +NEXT_PUBLIC_MARKETPLACE_API_URL="https://otqhrpbxhxkrhrnjqbba.supabase.co/" +NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_ZVVKKu1s88KsSBWVYlou-g_phb2OJVQ" diff --git a/apps/docs/app/guides/integrations/layout.tsx b/apps/docs/app/guides/integrations/layout.tsx index c319779007f..efd9c36710b 100644 --- a/apps/docs/app/guides/integrations/layout.tsx +++ b/apps/docs/app/guides/integrations/layout.tsx @@ -1,9 +1,14 @@ -import { IS_PLATFORM } from 'common' -import { unstable_cache } from 'next/cache' import { type NavMenuSection } from '~/components/Navigation/Navigation.types' import { REVALIDATION_TAGS } from '~/features/helpers.fetch' import Layout from '~/layouts/guides' -import { supabaseMisc } from '~/lib/supabaseMisc' +import { IS_PLATFORM } from 'common' +import { + createMarketplaceClient, + SUPABASE_LISTING_OVERRIDES, + SUPABASE_PARTNER_SLUG, + type CatalogListing, +} from 'common/marketplace-client' +import { unstable_cache } from 'next/cache' export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) { const additionalNavItems = { integrations: await getPartners() } @@ -16,26 +21,82 @@ export default async function IntegrationsLayout({ children }: { children: React const getPartners = unstable_cache(getPartnersImpl, [], { tags: [REVALIDATION_TAGS.PARTNERS], }) + async function getPartnersImpl() { if (!IS_PLATFORM) return [] - const { data, error } = await supabaseMisc() - .from('partners') - .select('slug, title') - .eq('approved', true) - .eq('type', 'technology') - .order('title') + const marketplaceClient = createMarketplaceClient() + + const [{ data: partners, error }, { data: listings }] = await Promise.all([ + marketplaceClient.from('partners').select('slug, name, type'), + marketplaceClient.from('catalog_listings').select('*'), + ]) if (error) { console.error(new Error('Error fetching partners', { cause: error })) } - const partnerNavItems = (data ?? []).map( - (partner) => - ({ - name: partner.title, - url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`, - }) as Partial - ) + // Only "technology" partners belong in this list, not agencies/experts. + const technologyPartnerSlugs = new Set() + const partnerNameBySlug = new Map() + for (const partner of partners ?? []) { + if (!partner.slug) continue + partnerNameBySlug.set(partner.slug, partner.name ?? partner.slug) + if (partner.type === 'technology') technologyPartnerSlugs.add(partner.slug) + } + + // Supabase-owned listings still get their own nav item, at their clean catalog page slug. + const supabaseOwnedNavItems: Partial[] = [] + const listingsByPartnerSlug = new Map() + + for (const listing of listings ?? []) { + if (!listing.slug) continue + + const override = SUPABASE_LISTING_OVERRIDES[listing.slug] + if (override) { + supabaseOwnedNavItems.push({ + name: listing.title || override.name, + url: `https://supabase.com/partners/catalog/${override.slug}` as `https://${string}`, + }) + continue + } + + const partnerSlug = listing.partner_slug + if ( + !partnerSlug || + partnerSlug === SUPABASE_PARTNER_SLUG || + !technologyPartnerSlugs.has(partnerSlug) + ) { + continue + } + + const partnerListings = listingsByPartnerSlug.get(partnerSlug) ?? [] + partnerListings.push(listing) + listingsByPartnerSlug.set(partnerSlug, partnerListings) + } + + // One nav item per listing (a partner with multiple listings — e.g. an FDW plus a + // Dashboard Integration — gets one entry per listing), linking straight to the tab + // that listing occupies on its Partner Catalog page. + const partnerNavItems: Partial[] = [ + ...Array.from(listingsByPartnerSlug.entries()).flatMap(([partnerSlug, partnerListings]) => { + const partnerName = partnerNameBySlug.get(partnerSlug) ?? partnerSlug + const catalogUrl = `https://supabase.com/partners/catalog/${partnerSlug}` + + // Show only listing name if partner has one listing + if (partnerListings.length === 1) { + const listingName = partnerListings[0].title || partnerName + return [{ name: listingName, url: catalogUrl as `https://${string}` }] + } + + // otherwise show listing name (or slug if no title) plus partner name. + return partnerListings.map((listing) => ({ + name: `${listing.title || listing.slug} (${partnerName})`, + url: `${catalogUrl}?tab=${listing.slug}` as `https://${string}`, + })) + }), + ...supabaseOwnedNavItems, + ] + partnerNavItems.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) return partnerNavItems } diff --git a/apps/docs/components/Navigation/Navigation.commands.tsx b/apps/docs/components/Navigation/Navigation.commands.tsx index 2a97ee8fc8c..0a00c936c99 100644 --- a/apps/docs/components/Navigation/Navigation.commands.tsx +++ b/apps/docs/components/Navigation/Navigation.commands.tsx @@ -146,8 +146,8 @@ const navCommands = [ }, { id: 'nav-integrations', - name: 'Go to Integrations', - route: 'https://supabase.com/partners/integrations', + name: 'Go to Partner Catalog', + route: 'https://supabase.com/partners/catalog', icon: () => , enabled: isFeatureEnabled('integrations:partners'), }, diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index d341727d4de..2c1ee5514fd 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -3233,12 +3233,16 @@ export const integrations: NavMenuConstant = { url: '/guides/integrations', }, { - name: 'Vercel Marketplace', - url: '/guides/integrations/vercel-marketplace', + name: 'Dashboard Integrations', + url: '/guides/integrations#dashboard-integrations', }, { - name: 'Supabase Marketplace', - url: '/guides/integrations/supabase-marketplace', + name: 'Partner Catalog', + url: '/guides/integrations/partner-catalog', + }, + { + name: 'Vercel Marketplace', + url: '/guides/integrations/vercel-marketplace', }, { name: 'Build Your Own', diff --git a/apps/docs/content/guides/integrations.mdx b/apps/docs/content/guides/integrations.mdx index 579b0fdcd36..8b0144d7610 100644 --- a/apps/docs/content/guides/integrations.mdx +++ b/apps/docs/content/guides/integrations.mdx @@ -4,10 +4,14 @@ title: Integrations Supabase integrates with many of your favorite third-party services. +## Dashboard Integrations + +Install and manage extensions, wrappers, and Postgres Modules directly into your project in a couple of clicks. [Browse Dashboard Integrations](/dashboard/project/_/integrations) + +## Partner Catalog + +Browse a curated list of Partners who offer one-click integrations, guides or other ways to extend your Supabase project. [Browse the Partner Catalog](/partners/catalog) + ## Vercel Marketplace Create and manage your Supabase projects directly through Vercel. [Get started with Vercel](/docs/guides/integrations/vercel-marketplace). - -## Supabase Marketplace - -Browse tools for extending your Supabase project. [Browse the Supabase Marketplace](/partners/integrations). diff --git a/apps/docs/content/guides/integrations/supabase-marketplace.mdx b/apps/docs/content/guides/integrations/partner-catalog.mdx similarity index 71% rename from apps/docs/content/guides/integrations/supabase-marketplace.mdx rename to apps/docs/content/guides/integrations/partner-catalog.mdx index b196fca240f..e6e59da9cfb 100644 --- a/apps/docs/content/guides/integrations/supabase-marketplace.mdx +++ b/apps/docs/content/guides/integrations/partner-catalog.mdx @@ -1,13 +1,12 @@ --- id: 'integrations' -title: 'Supabase Marketplace' +title: 'Partner Catalog' description: 'Integrations and Partners' --- -The Supabase Marketplace brings together all the tools you need to extend your Supabase project. This includes: +The [Partner Catalog](/partners/catalog) is Supabase's public directory of third-party integrations. It lists tools that extend your Supabase project. These tools cover Auth, Caching, Hosting, and Low-code categories. -- [Experts](/partners/experts) - partners to help you build and support your Supabase project. -- [Integrations](/partners/integrations) - extend your projects with external Auth, Caching, Hosting, and Low-code tools. +The Partner Catalog is different from [Dashboard Integrations](/guides/integrations#dashboard-integrations). You install Dashboard Integrations directly from a project in the Supabase Dashboard. ## Build an integration @@ -20,12 +19,12 @@ Supabase provides several integration points: ## List your integration -[Apply to the Partners program](/partners/integrations#become-a-partner) to list your integration in the Partners marketplace and in the Supabase docs. +[Apply to the Partners program](/partners/catalog#become-a-partner) to list your integration in the Partner Catalog and in the Supabase docs. Integrations are assessed on the following criteria: - **Business viability** - While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the marketplace. + While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the catalog. - **Compliance** Integrations should not infringe on the Supabase brand/trademark. In short, you cannot use "Supabase" in the name. As the listing appears on the Supabase domain, we don't want to mislead developers into thinking that an integration is an official product. - **Service Level Agreements** diff --git a/apps/docs/lib/supabaseMisc.ts b/apps/docs/lib/supabaseMisc.ts deleted file mode 100644 index 2c70c480b9d..00000000000 --- a/apps/docs/lib/supabaseMisc.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createClient, type SupabaseClient } from '@supabase/supabase-js' - -let _supabaseMisc: SupabaseClient - -export function supabaseMisc() { - if (!_supabaseMisc) { - _supabaseMisc = createClient( - process.env.NEXT_PUBLIC_MISC_URL!, - process.env.NEXT_PUBLIC_MISC_ANON_KEY! - ) - } - - return _supabaseMisc -} diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index 6ffd9e5257a..f46ce1cc5df 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -24,6 +24,8 @@ "NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_MISC_URL", "NEXT_PUBLIC_MISC_ANON_KEY", + "NEXT_PUBLIC_MARKETPLACE_API_URL", + "NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY", "NODE_ENV", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_BASE_PATH", diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx index e80fd564f0f..2ae515958f8 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx @@ -1,26 +1,4 @@ -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 { BadgeCheck, Cable, Database, Package2, Plug, Users, type LucideIcon } from 'lucide-react' import type { ReactNode } from 'react' import { Badge, cn, IconPartners } from 'ui' @@ -29,6 +7,8 @@ import type { MarketplaceSource, } from '@/components/interfaces/Integrations/Landing/Integrations.constants' +export { getCategoryIcon, CATEGORY_ICONS } from 'common/marketplace-categories' + export type { MarketplaceSource } from '@/components/interfaces/Integrations/Landing/Integrations.constants' // Defines featured integrations and their order in the featured hero @@ -47,29 +27,6 @@ export const INTEGRATION_TYPES: Array<{ { 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 = { - 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' }, @@ -87,11 +44,6 @@ export const EXCLUDED_CATEGORY_SLUGS = new Set([ '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 }> diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx index db457a62656..2314874e1dc 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx @@ -229,7 +229,7 @@ export const MarketplaceIndex = () => {
- +
diff --git a/apps/www/app/partners/[slug]/page.tsx b/apps/www/app/partners/[slug]/page.tsx new file mode 100644 index 00000000000..4aec577e86d --- /dev/null +++ b/apps/www/app/partners/[slug]/page.tsx @@ -0,0 +1,21 @@ +import supabase from '~/lib/supabaseMisc' +import { notFound, redirect } from 'next/navigation' + +type Params = { slug: string } + +export const dynamic = 'force-dynamic' + +export default async function PartnerLegacyPage({ params }: { params: Promise }) { + const { slug } = await params + + const { data: partner } = await supabase + .from('partners') + .select('slug, type') + .eq('approved', true) + .eq('slug', slug) + .single() + + if (!partner || partner.type === 'expert') notFound() + + redirect(`/partners/catalog/${partner.slug}`) +} diff --git a/apps/www/app/partners/catalog/IntegrationsContent.tsx b/apps/www/app/partners/catalog/IntegrationsContent.tsx new file mode 100644 index 00000000000..744f69d3264 --- /dev/null +++ b/apps/www/app/partners/catalog/IntegrationsContent.tsx @@ -0,0 +1,458 @@ +'use client' + +import DefaultLayout from '~/components/Layouts/Default' +import SectionContainer from '~/components/Layouts/SectionContainer' +import Panel from '~/components/Panel' +import { searchCatalogPartners } from '~/lib/marketplaceDb' +import type { Partner } from '~/types/partners' +import { getCategoryIcon } from 'common/marketplace-categories' +import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll' +import { ArrowRight, ArrowUpRight, Filter, LayoutGrid, List, Loader, Search } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { + parseAsArrayOf, + parseAsBoolean, + parseAsString, + parseAsStringEnum, + useQueryStates, +} from 'nuqs' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Badge, + Button, + Checkbox, + cn, + InputGroup, + InputGroupAddon, + InputGroupInput, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from 'ui' +import { useDebounce } from 'use-debounce' + +interface Props { + initialPartners: Partner[] + metaTitle: string + metaDescription: string +} + +type ViewMode = 'grid' | 'list' + +const OFFICIAL_PARTNER_SLUGS = new Set(['grafana', 'stripe', 'aikido', 'doppler', 'resend']) +const PARTNERS_PAGE_SIZE = 24 + +export default function IntegrationsContent({ + initialPartners, + metaTitle, + metaDescription, +}: Props) { + const [partners, setPartners] = useState(initialPartners) + // Deduplicate by slug, keep name for display, sort alphabetically. + const allCategories = Array.from( + new Map(initialPartners?.flatMap((p) => p.categories).map((c) => [c.slug, c]) ?? []).values() + ).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + + const [{ q: search, cat: selectedCategories, partner: partnerOnly, view: viewMode }, setFilters] = + useQueryStates( + { + q: parseAsString.withDefault(''), + cat: parseAsArrayOf(parseAsString).withDefault([]), + partner: parseAsBoolean.withDefault(false), + view: parseAsStringEnum(['grid', 'list']).withDefault('grid'), + }, + { history: 'replace' } + ) + + const [debouncedSearchTerm] = useDebounce(search, 300) + const [isSearching, setIsSearching] = useState(false) + const searchIdRef = useRef(0) + + useEffect(() => { + if (debouncedSearchTerm.trim() === '') { + setIsSearching(false) + setPartners(initialPartners) + return + } + setIsSearching(true) + const currentSearchId = ++searchIdRef.current + searchCatalogPartners(debouncedSearchTerm) + .then((results) => { + if (currentSearchId === searchIdRef.current) setPartners(results ?? []) + }) + .catch(() => { + if (currentSearchId === searchIdRef.current) setPartners([]) + }) + .finally(() => { + if (currentSearchId === searchIdRef.current) setIsSearching(false) + }) + }, [debouncedSearchTerm, initialPartners]) + + // selectedCategories holds slugs; toggle by slug + const handleCategoryChange = (slug: string) => + setFilters({ + cat: selectedCategories.includes(slug) + ? selectedCategories.filter((s) => s !== slug) + : [...selectedCategories, slug], + }) + + const HAS_ACTIVE_FILTERS = search.trim() !== '' || selectedCategories.length > 0 || partnerOnly + const activeFilterCount = selectedCategories.length + (partnerOnly ? 1 : 0) + + const filtered = useMemo(() => { + const categoryFiltered = + selectedCategories.length > 0 + ? partners.filter((p) => p.categories.some((c) => selectedCategories.includes(c.slug))) + : partners + + return partnerOnly + ? categoryFiltered.filter((p) => OFFICIAL_PARTNER_SLUGS.has(p.slug)) + : categoryFiltered + }, [partners, selectedCategories, partnerOnly]) + + const featuredPartners = useMemo( + () => filtered.filter((p) => p.featured).sort((a, b) => a.title.localeCompare(b.title)), + [filtered] + ) + const listPartners = useMemo( + () => + HAS_ACTIVE_FILTERS + ? [...filtered.filter((p) => p.featured), ...filtered.filter((p) => !p.featured)] + : filtered.filter((p) => !p.featured), + [filtered, HAS_ACTIVE_FILTERS] + ) + const showFeatured = !HAS_ACTIVE_FILTERS && featuredPartners.length > 0 + + // Only render the first page of the (already-fetched, in-memory) partner list up front, + // then reveal more as the sentinel below the grid/list scrolls into view — avoids mounting + // every partner card (and its logo ) on initial load. + const initialVisiblePartners = useMemo( + () => listPartners.slice(0, PARTNERS_PAGE_SIZE), + [listPartners] + ) + const fetchMoreVisiblePartners = useCallback( + async (offset: number, limit: number) => listPartners.slice(offset, offset + limit), + [listPartners] + ) + const { + items: visiblePartners, + hasMore: hasMorePartners, + loadMoreRef: partnersLoadMoreRef, + } = useInfiniteScrollWithFetch({ + initialItems: initialVisiblePartners, + totalItems: listPartners.length, + pageSize: PARTNERS_PAGE_SIZE, + fetchMore: fetchMoreVisiblePartners, + }) + + // Shared filter UI — rendered in both the desktop sidebar and the mobile bottom sheet. + // Uses wrapping