From 471e8f0e49e9eb2da327de32b5afacbcd3e136f7 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 27 May 2026 16:25:01 +0200 Subject: [PATCH] update partner detail with multiple listings --- .../partners/catalog/IntegrationsContent.tsx | 32 +- apps/www/data/partner-integration-listings.ts | 84 +++ apps/www/lib/marketplaceDb.ts | 10 +- apps/www/lib/partnerIntegrationListings.ts | 43 ++ apps/www/lib/redirects.js | 18 +- apps/www/pages/partners/catalog/[slug].tsx | 532 +++++++++++------- apps/www/types/partners.ts | 18 + 7 files changed, 509 insertions(+), 228 deletions(-) create mode 100644 apps/www/data/partner-integration-listings.ts create mode 100644 apps/www/lib/partnerIntegrationListings.ts diff --git a/apps/www/app/partners/catalog/IntegrationsContent.tsx b/apps/www/app/partners/catalog/IntegrationsContent.tsx index de3e77f0fff..6da9eff20b0 100644 --- a/apps/www/app/partners/catalog/IntegrationsContent.tsx +++ b/apps/www/app/partners/catalog/IntegrationsContent.tsx @@ -3,7 +3,9 @@ import DefaultLayout from '~/components/Layouts/Default' import SectionContainer from '~/components/Layouts/SectionContainer' import Panel from '~/components/Panel' +import { PARTNER_INTEGRATION_OVERRIDES } from '~/data/partner-integration-listings' import { searchPartners } from '~/lib/marketplaceDb' +import { ABSORBED_SLUGS } from '~/lib/partnerIntegrationListings' import type { Partner } from '~/types/partners' import { ArrowRight, ArrowUpRight, LayoutGrid, List, Loader, Search } from 'lucide-react' import Image from 'next/image' @@ -25,9 +27,15 @@ export default function IntegrationsContent({ metaTitle, metaDescription, }: Props) { - const [partners, setPartners] = useState(initialPartners) + const [partners, setPartners] = useState(() => + initialPartners.filter((p) => !ABSORBED_SLUGS.has(p.slug)) + ) const allCategories = Array.from( - new Set(initialPartners?.flatMap((p) => p.categories.map((c) => c.name))) + new Set( + initialPartners + .filter((p) => !ABSORBED_SLUGS.has(p.slug)) + .flatMap((p) => p.categories.map((c) => c.name)) + ) ).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) const [search, setSearch] = useState('') @@ -49,7 +57,8 @@ export default function IntegrationsContent({ const currentSearchId = ++searchIdRef.current searchPartners(debouncedSearchTerm) .then((results) => { - if (currentSearchId === searchIdRef.current) setPartners(results ?? []) + if (currentSearchId === searchIdRef.current) + setPartners((results ?? []).filter((p) => !ABSORBED_SLUGS.has(p.slug))) }) .catch(() => { if (currentSearchId === searchIdRef.current) setPartners([]) @@ -69,6 +78,8 @@ export default function IntegrationsContent({ // When isUseMarketplaceDb is active, p.isMarketplace handles it automatically. const MARKETPLACE_SLUGS = new Set(['grafana', 'stripe', 'aikido', 'doppler', 'resend']) const isOneClick = (p: Partner) => MARKETPLACE_SLUGS.has(p.slug) + const hasDashboardIntegrations = (p: Partner) => + (PARTNER_INTEGRATION_OVERRIDES[p.slug]?.length ?? 0) > 0 const HAS_ACTIVE_FILTERS = search.trim() !== '' || selectedCategories.length > 0 || partnerOnly || oneClickOnly @@ -233,6 +244,11 @@ export default function IntegrationsContent({ {p.categories.map((c) => ( {c.name} ))} + {hasDashboardIntegrations(p) && ( + + Marketplace + + )} @@ -318,6 +334,11 @@ export default function IntegrationsContent({ {c.name} ))} + {hasDashboardIntegrations(p) && ( + + Dashboard + + )} @@ -363,6 +384,11 @@ export default function IntegrationsContent({ {c.name} ))} + {hasDashboardIntegrations(p) && ( + + Dashboard + + )}

{p.description}

diff --git a/apps/www/data/partner-integration-listings.ts b/apps/www/data/partner-integration-listings.ts new file mode 100644 index 00000000000..66ea3e58dbe --- /dev/null +++ b/apps/www/data/partner-integration-listings.ts @@ -0,0 +1,84 @@ +import type { IntegrationListing } from '~/types/partners' + +type StudioIntegrationEntry = Omit< + IntegrationListing, + 'content' | 'images' | 'youtubeId' | 'installUrl' | 'isMarketplace' +> & { + type: 'wrapper' | 'template' + dashboardUrl: string +} + +/** + * Additional Studio-managed integration surfaces for catalog partners. + * Keyed by the partner's catalog slug. + * + * The "guide" listing is always derived from the Partner DB record itself. + * These entries appear as extra tabs when a partner has more than one integration surface. + * + * Adding a partner here is appropriate when: + * - The catalog entry is a general integration guide (e.g. an auth guide), AND + * - There is also a separate Studio wrapper/template that lets users query that partner's data + * via SQL or install a dashboard integration — a genuinely different surface. + * + * Do NOT add a partner here when the catalog content IS already the wrapper guide + * (single listing is correct in that case — the extra tab would just duplicate content). + * + * Pending content audit before adding: airtable, clickhouse, snowflake, paddle, + * notion, slack, hubspot, calendly, cal-com, orb, cloudflare-workers (D1 wrapper). + */ +export const PARTNER_INTEGRATION_OVERRIDES: Record = { + // ── Stripe ────────────────────────────────────────────────────────────────── + // Catalog: general payments integration guide + // + FDW: query Stripe objects via SQL + // + Template: full bidirectional sync engine + stripe: [ + { + slug: 'stripe-wrapper', + name: 'Foreign Data Wrapper', + type: 'wrapper', + description: + 'Query your Stripe data directly via SQL using the Stripe Foreign Data Wrapper. Read accounts, customers, subscriptions, invoices, and more as Postgres tables.', + docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/stripe', + dashboardUrl: '/dashboard/project/_/integrations/stripe_wrapper/wrappers', + }, + { + slug: 'stripe-sync-engine', + name: 'Stripe Sync Engine', + type: 'template', + description: + 'Continuously sync your Stripe payments, customers, and subscription data into your Postgres database using Supabase Queues and Edge Functions.', + docsUrl: 'https://github.com/stripe-experiments/sync-engine/', + dashboardUrl: '/dashboard/project/_/integrations/stripe_sync_engine', + }, + ], + + // ── Auth0 ──────────────────────────────────────────────────────────────────── + // Catalog: auth integration guide (JWT / user migration) + // + FDW: query Auth0 users directly from Postgres + auth0: [ + { + slug: 'auth0-wrapper', + name: 'Foreign Data Wrapper', + type: 'wrapper', + description: + 'Query your Auth0 users and management data directly from Postgres using the Auth0 Foreign Data Wrapper.', + docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/auth0', + dashboardUrl: '/dashboard/project/_/integrations/auth0_wrapper/wrappers', + }, + ], + + // ── Clerk ──────────────────────────────────────────────────────────────────── + // Catalog: auth integration guide (JWT / session integration) + // + FDW: query Clerk users, organizations, and more from Postgres + clerk: [ + { + slug: 'clerk-wrapper', + name: 'Foreign Data Wrapper', + type: 'wrapper', + description: + 'Query your Clerk users, organizations, memberships, and more directly from Postgres using the Clerk Foreign Data Wrapper.', + docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/clerk', + dashboardUrl: '/dashboard/project/_/integrations/clerk_wrapper/wrappers', + }, + ], +} diff --git a/apps/www/lib/marketplaceDb.ts b/apps/www/lib/marketplaceDb.ts index 5949e916cd3..8200c431223 100644 --- a/apps/www/lib/marketplaceDb.ts +++ b/apps/www/lib/marketplaceDb.ts @@ -154,12 +154,10 @@ export async function searchPartners(search: string): Promise } async function getMarketplaceListing(slug: string): Promise { - const { data } = await marketplaceClient - .from('listings') - .select('*') - .eq('slug', slug) - .is('publish_marketplace', true) - .single() + // Do NOT filter by publish_marketplace here — the detail page should be reachable + // for any listing by direct URL, even if that listing isn't shown in the catalog list. + // publish_marketplace only gates the LIST query (listPartners / listPartnerSlugs). + const { data } = await marketplaceClient.from('listings').select('*').eq('slug', slug).single() return data ? toPartner(data) : null } diff --git a/apps/www/lib/partnerIntegrationListings.ts b/apps/www/lib/partnerIntegrationListings.ts new file mode 100644 index 00000000000..f2073c43b94 --- /dev/null +++ b/apps/www/lib/partnerIntegrationListings.ts @@ -0,0 +1,43 @@ +import { PARTNER_INTEGRATION_OVERRIDES } from '~/data/partner-integration-listings' +import type { IntegrationListing, Partner } from '~/types/partners' + +/** + * Derives the list of IntegrationListings for a given partner. + * + * The first entry is always a "guide" listing built from the Partner DB record. + * Additional entries come from PARTNER_INTEGRATION_OVERRIDES (Studio wrappers, templates, etc.). + * When there is only one listing the detail page renders its content directly (no tabs). + * When there are multiple listings the detail page renders them as navigable tabs. + */ +export function partnerToIntegrationListings(partner: Partner): IntegrationListing[] { + const guide: IntegrationListing = { + slug: 'guide', + name: 'Guide', + type: 'guide', + description: partner.description, + content: partner.content, + images: partner.images, + youtubeId: partner.youtubeId, + installUrl: partner.installUrl, + isMarketplace: partner.isMarketplace, + docsUrl: partner.docsUrl, + } + + const overrides = PARTNER_INTEGRATION_OVERRIDES[partner.slug] ?? [] + + return [guide, ...overrides] +} + +/** Returns true when the partner has more than one integration surface. */ +export function hasMultipleListings(partnerSlug: string): boolean { + return (PARTNER_INTEGRATION_OVERRIDES[partnerSlug]?.length ?? 0) > 0 +} + +/** + * The set of catalog slugs that have been absorbed as tabs into another partner's page. + * These slugs may exist as standalone DB entries but should not appear in the catalog list + * or generate their own static pages — they are reachable only via the parent partner's tab. + */ +export const ABSORBED_SLUGS: ReadonlySet = new Set( + Object.values(PARTNER_INTEGRATION_OVERRIDES).flatMap((entries) => entries.map((e) => e.slug)) +) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index f695ae0d57c..ae0a6d105cd 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -2312,7 +2312,23 @@ module.exports = [ { permanent: true, source: '/partners/integrations/supabase_wrapper_stripe', - destination: '/partners/catalog/stripe-wrapper', + destination: '/partners/catalog/stripe?tab=stripe-wrapper', + }, + // Legacy direct slugs for FDW pages — now tabs on the partner page + { + permanent: true, + source: '/partners/catalog/stripe-wrapper', + destination: '/partners/catalog/stripe?tab=stripe-wrapper', + }, + { + permanent: true, + source: '/partners/catalog/auth0-wrapper', + destination: '/partners/catalog/auth0?tab=auth0-wrapper', + }, + { + permanent: true, + source: '/partners/catalog/clerk-wrapper', + destination: '/partners/catalog/clerk?tab=clerk-wrapper', }, { permanent: true, diff --git a/apps/www/pages/partners/catalog/[slug].tsx b/apps/www/pages/partners/catalog/[slug].tsx index 93e7c2fbf2d..77465aca7b9 100644 --- a/apps/www/pages/partners/catalog/[slug].tsx +++ b/apps/www/pages/partners/catalog/[slug].tsx @@ -9,6 +9,8 @@ import { serialize } from 'next-mdx-remote-client/serialize' import { NextSeo } from 'next-seo' import Image from 'next/image' import Link from 'next/link' +import { parseAsString, useQueryState } from 'nuqs' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { useState, type Dispatch, type SetStateAction } from 'react' import remarkGfm from 'remark-gfm' @@ -17,34 +19,31 @@ import 'swiper/css' import DefaultLayout from '~/components/Layouts/Default' import SectionContainer from '~/components/Layouts/SectionContainer' import { getPartner, listPartnerSlugs } from '~/lib/marketplaceDb' -import { type Partner } from '~/types/partners' +import { ABSORBED_SLUGS, partnerToIntegrationListings } from '~/lib/partnerIntegrationListings' +import { type IntegrationListing, type Partner } from '~/types/partners' import { useBreakpoint } from 'common' import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' } import { Swiper, SwiperSlide } from 'swiper/react' -import { Button } from 'ui' +import { + Button, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' import { Admonition } from 'ui-patterns/admonition' import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' import Error404 from '../../404' -/** - * Returns custom components so that the markdown converts to a nice looking html. - */ function mdxComponents(callback: Dispatch>) { - const components = { + return { CH, Admonition, - /** - * Returns a custom img element which has a bound onClick listener. When the image is clicked, it will open a modal showing that particular image. - */ img: ( props: React.DetailedHTMLProps, HTMLImageElement> - ) => { - return callback(props.src!.toString())} /> - }, + ) => callback(props.src!.toString())} />, } - - return components } type PartnerData = { @@ -52,9 +51,170 @@ type PartnerData = { overview: MDXRemoteSerializeResult, Record> } -function Partner({ partner, overview }: PartnerData) { +// ─── Tab content renderers ──────────────────────────────────────────────────── + +function GuideTab({ + listing, + overview, + focusedImage, + setFocusedImage, + partner, + isNarrow, +}: { + listing: IntegrationListing + overview: PartnerData['overview'] + focusedImage: string | null + setFocusedImage: Dispatch> + partner: Partner + isNarrow: boolean +}) { + return ( + <> + {(listing.images?.length ?? 0) > 0 && ( +
+ + + {listing.images?.map((image, i) => ( + +
+ + {focusedImage !== image && ( + setFocusedImage(image)} + > + {partner.title} + + )} + +
+
+ ))} +
+
+
+ )} + +
+ {isNarrow && } +
+

+ Overview +

+
+ {'error' in overview ? ( +

Error rendering integration page: {overview.error.message}

+ ) : ( + // @ts-expect-error: CodeHike puts its components under the CH namespace + + )} +
+
+ {!isNarrow && } +
+ + {listing.installUrl && ( +
+
+

Get started with {partner.title} and Supabase.

+ + + +
+
+ )} + + ) +} + +function DashboardIntegrationTab({ + listing, + partner, + isNarrow, +}: { + listing: IntegrationListing + partner: Partner + isNarrow: boolean +}) { + const typeLabel = listing.type === 'wrapper' ? 'Foreign Data Wrapper' : 'Dashboard Integration' + + return ( +
+ {isNarrow && } +
+
+

+ {listing.name} +

+ {listing.description && ( +

{listing.description}

+ )} +
+ +
+ {listing.dashboardUrl && ( + + )} + {listing.docsUrl && ( + + )} +
+
+ {!isNarrow && } +
+ ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +function PartnerInner({ partner, overview }: PartnerData) { const [focusedImage, setFocusedImage] = useState(null) const isNarrow = useBreakpoint('lg') + const listings = partnerToIntegrationListings(partner) + const isMulti = listings.length > 1 + + const [activeTabSlug, setActiveTabSlug] = useQueryState('tab', parseAsString) + const activeTab = listings.find((l) => l.slug === activeTabSlug) ?? listings[0] if (!partner) return @@ -67,11 +227,7 @@ function Partner({ partner, overview }: PartnerData) { title: `${partner.title} | Works With Supabase`, description: partner.description, url: `https://supabase.com/partners/catalog/${partner.slug}`, - images: [ - { - url: partner.images ? partner.images[0] : partner.logo, - }, - ], + images: [{ url: partner.images?.[0] ?? partner.logo }], }} /> @@ -89,7 +245,7 @@ function Partner({ partner, overview }: PartnerData) { e.stopPropagation()} > )} +
- {/* Back button */} + {/* Back */} + {/* Partner header */}
{partner.title} @@ -129,122 +287,75 @@ function Partner({ partner, overview }: PartnerData) { {partner.title}
- {partner.installUrl && ( + {!isMulti && activeTab.installUrl && ( )}
-
- {(partner.images?.length ?? 0) > 0 && ( - - - {partner.images?.map((image: any, i: number) => { - return ( - -
- - {focusedImage !== image && ( - setFocusedImage(image)} - > - {partner.title} - - )} - -
-
- ) - })} -
-
- )} -
- -
- {isNarrow && } - -
-

setActiveTabSlug(slug === listings[0].slug ? null : slug)} + className="mt-8" + > + {/* Full-bleed border — mirrors the SectionContainer structure so tabs align with content */} +
- Overview -

- -
- {'error' in overview ? ( -

Error rendering integration page: {overview.error.message}

- ) : ( - // @ts-expect-error: Gildas - This is because CodeHide put its components under the CH namespace. It works but the MDXClient types are stricter - - )} +
+
+ + {listings.map((listing) => ( + + {listing.name} + + ))} + +
+
-
- {!isNarrow && } -
- {partner.installUrl && ( -
-
-

- Get started with {partner.title} and Supabase. -

- - - -
-
+ {listings.map((listing) => ( + + {listing.type === 'guide' ? ( + + ) : ( + + )} + + ))} + + ) : ( + // Single listing: render content directly, no tabs + )}
@@ -253,113 +364,102 @@ function Partner({ partner, overview }: PartnerData) { ) } -const PartnerDetails = ({ partner }: { partner: Partner }) => { - const videoThumbnail = partner.youtubeId - ? `https://img.youtube.com/vi/${partner.youtubeId}/0.jpg` - : undefined - +function Partner({ partner, overview }: PartnerData) { + if (!partner) return return ( -
-
-

- Details -

+ + + + ) +} - {partner.youtubeId && ( - +// ─── Details sidebar ───────────────────────────────────────────────────────── + +const PartnerDetails = ({ partner }: { partner: Partner }) => ( +
+
+

+ Details +

+ + {partner.youtubeId && ( + + )} + +
+ {partner.type === 'technology' && ( +
+ Developer + {partner.partnerName} +
)} -
- {partner.type === 'technology' && ( -
- Developer - {partner.partnerName} -
- )} + {partner.categories.map((category) => ( +
+ Category + + {category.name} + +
+ ))} - {partner.categories.map((category) => ( -
- Category - - {category.name} - -
- ))} + + {partner.type === 'technology' && partner.docsUrl && ( - - {partner.type === 'technology' && partner.docsUrl && ( -
- Documentation - - - Learn - - - -
- )} -
-

- Third-party integrations and docs are managed by Supabase partners. -

+ )}
-
- ) -} -// This function gets called at build time +

+ Third-party integrations and docs are managed by Supabase partners. +

+
+
+) + +// ─── Data fetching ──────────────────────────────────────────────────────────── + export const getStaticPaths: GetStaticPaths = async () => { const slugs = await listPartnerSlugs() - - const paths: { - params: { slug: string } - locale?: string | undefined - }[] = - slugs?.map((slug) => ({ - params: { - slug, - }, - })) ?? [] - - return { - paths, - fallback: 'blocking', - } + const paths = + slugs?.filter((slug) => !ABSORBED_SLUGS.has(slug)).map((slug) => ({ params: { slug } })) ?? [] + return { paths, fallback: 'blocking' } } -// This also gets called at build time export const getStaticProps: GetStaticProps = async ({ params }) => { const partner = await getPartner(params!.slug as string) - if (!partner) { - return { - notFound: true, - } - } + if (!partner) return { notFound: true } const codeHikeOptions: CodeHikeConfig = { theme: codeHikeTheme, @@ -369,7 +469,6 @@ export const getStaticProps: GetStaticProps = async ({ params }) => autoImport: false, } - // Parse markdown const overview = await serialize({ source: partner.content, options: { @@ -379,10 +478,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => }, }) - return { - props: { partner, overview }, - revalidate: 1800, // 30 minutes - } + return { props: { partner, overview }, revalidate: 1800 } } export default Partner diff --git a/apps/www/types/partners.ts b/apps/www/types/partners.ts index 3475ae35d4b..79179f2c2e5 100644 --- a/apps/www/types/partners.ts +++ b/apps/www/types/partners.ts @@ -25,6 +25,24 @@ export type Partner = { youtubeId: string | null } +export type IntegrationListingType = 'guide' | 'wrapper' | 'template' + +export type IntegrationListing = { + slug: string + name: string + type: IntegrationListingType + description?: string + // guide-specific + content?: string | null + images?: string[] + youtubeId?: string | null + installUrl?: string | null + isMarketplace?: boolean + docsUrl?: string | null + // wrapper / template: links into the Studio dashboard + dashboardUrl?: string +} + export function toPartner(dbPartner: DbPartner): Partner { const { featured,