update partner detail with multiple listings

This commit is contained in:
Francesco Sansalvadore committed 2026-05-27 16:25:01 +02:00
1 parent fe3290c01b
commit 471e8f0e49
7 files changed
+509 -228

No files matched your search

@@ -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) => (
<Badge key={c.name}>{c.name}</Badge>
))}
{hasDashboardIntegrations(p) && (
<span className="inline-flex items-center text-[10px] font-medium px-1.5 py-0 h-4 rounded bg-brand/10 text-brand border border-brand/20">
Marketplace
</span>
)}
</div>
</div>
</div>
@@ -318,6 +334,11 @@ export default function IntegrationsContent({
{c.name}
</span>
))}
{hasDashboardIntegrations(p) && (
<span className="inline-flex items-center text-[10px] font-medium px-1.5 py-0 h-4 rounded bg-brand/10 text-brand border border-brand/20">
Dashboard
</span>
)}
</div>
</div>
</Panel>
@@ -363,6 +384,11 @@ export default function IntegrationsContent({
{c.name}
</span>
))}
{hasDashboardIntegrations(p) && (
<span className="inline-flex items-center text-[10px] font-medium px-1.5 py-0 h-4 rounded bg-brand/10 text-brand border border-brand/20">
Dashboard
</span>
)}
</div>
</div>
<p className="text-foreground-lighter text-sm truncate">{p.description}</p>
@@ -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<string, StudioIntegrationEntry[]> = {
// ── 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',
},
],
}
+4 -6
View File
@@ -154,12 +154,10 @@ export async function searchPartners(search: string): Promise<Partner[] | null>
}
async function getMarketplaceListing(slug: string): Promise<Partner | null> {
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
}
@@ -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<string> = new Set(
Object.values(PARTNER_INTEGRATION_OVERRIDES).flatMap((entries) => entries.map((e) => e.slug))
)
+17 -1
View File
@@ -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,
+314 -218
View File
@@ -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<SetStateAction<string | null>>) {
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<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
) => {
return <img {...props} onClick={() => callback(props.src!.toString())} />
},
) => <img {...props} onClick={() => callback(props.src!.toString())} />,
}
return components
}
type PartnerData = {
@@ -52,9 +51,170 @@ type PartnerData = {
overview: MDXRemoteSerializeResult<Record<string, unknown>, Record<string, unknown>>
}
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<SetStateAction<string | null>>
partner: Partner
isNarrow: boolean
}) {
return (
<>
{(listing.images?.length ?? 0) > 0 && (
<div
className="bg-linear-to-t from-background-alternative to-background border-b p-6 [&_.swiper]:overflow-visible! [&_.swiper-wrapper]:overflow-visible!"
style={{ marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' }}
>
<SectionContainer className="py-0! px-3! lg:px-12! xl:p-0! mx-auto max-w-5xl mt-32">
<Swiper
initialSlide={0}
spaceBetween={20}
slidesPerView={4}
speed={300}
grabCursor
centeredSlides={false}
centerInsufficientSlides={false}
breakpoints={{
320: { slidesPerView: 1.15, centeredSlides: false, spaceBetween: 10 },
720: { slidesPerView: 1.75, centeredSlides: false, spaceBetween: 10 },
920: { slidesPerView: 2.5, centeredSlides: false },
1024: { slidesPerView: 3 },
1280: { slidesPerView: 4 },
}}
>
{listing.images?.map((image, i) => (
<SwiperSlide key={i}>
<div className="relative aspect-16/8">
<AnimatePresence>
{focusedImage !== image && (
<motion.div
key={`thumb-${image}`}
layoutId={`partner-image-${image}`}
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
className="absolute inset-0 cursor-zoom-in rounded-md border bg-muted"
onClick={() => setFocusedImage(image)}
>
<Image
placeholder="blur"
blurDataURL="/images/blur.png"
fill
sizes="(min-width: 1280px) 25vw, (min-width: 1024px) 33vw, (min-width: 920px) 40vw, (min-width: 720px) 60vw, 90vw"
src={image}
alt={partner.title}
className="rounded-md object-cover"
/>
</motion.div>
)}
</AnimatePresence>
</div>
</SwiperSlide>
))}
</Swiper>
</SectionContainer>
</div>
)}
<div className="grid gap-y-12 lg:grid-cols-8 lg:gap-x-20 mt-16">
{isNarrow && <PartnerDetails partner={partner} />}
<div className="lg:col-span-5 overflow-hidden">
<h2 className="text-foreground" style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>
Overview
</h2>
<div className="prose">
{'error' in overview ? (
<p>Error rendering integration page: {overview.error.message}</p>
) : (
// @ts-expect-error: CodeHike puts its components under the CH namespace
<MDXClient {...overview} components={mdxComponents(setFocusedImage)} />
)}
</div>
</div>
{!isNarrow && <PartnerDetails partner={partner} />}
</div>
{listing.installUrl && (
<div className="bg-background hover:border-default-control border-default rounded-2xl border p-10 drop-shadow-xs max-w-5xl mx-auto mt-12">
<div className="flex flex-row justify-between">
<h1 className="text-2xl self-center">Get started with {partner.title} and Supabase.</h1>
<a href={listing.installUrl} target="_blank" rel="noreferrer">
<Button size="medium" type="secondary">
Add integration
</Button>
</a>
</div>
</div>
)}
</>
)
}
function DashboardIntegrationTab({
listing,
partner,
isNarrow,
}: {
listing: IntegrationListing
partner: Partner
isNarrow: boolean
}) {
const typeLabel = listing.type === 'wrapper' ? 'Foreign Data Wrapper' : 'Dashboard Integration'
return (
<div className="grid gap-y-12 lg:grid-cols-8 lg:gap-x-20 mt-16">
{isNarrow && <PartnerDetails partner={partner} />}
<div className="lg:col-span-5 flex flex-col gap-8">
<div>
<h2 className="text-foreground" style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>
{listing.name}
</h2>
{listing.description && (
<p className="text-foreground-light text-base leading-relaxed">{listing.description}</p>
)}
</div>
<div className="flex flex-col sm:flex-row items-start gap-3">
{listing.dashboardUrl && (
<Button asChild size="medium">
<a href={listing.dashboardUrl}>Install integration</a>
</Button>
)}
{listing.docsUrl && (
<Button asChild size="medium" type="default">
<a href={listing.docsUrl} target="_blank" rel="noreferrer">
<span className="flex items-center gap-1.5">
{typeLabel} docs
<ExternalLink size={14} />
</span>
</a>
</Button>
)}
</div>
</div>
{!isNarrow && <PartnerDetails partner={partner} />}
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
function PartnerInner({ partner, overview }: PartnerData) {
const [focusedImage, setFocusedImage] = useState<string | null>(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 <Error404 />
@@ -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) {
<motion.div
layoutId={`partner-image-${focusedImage}`}
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
className="relative w-full max-w-6xl aspect-[16/8] rounded-md border bg-muted"
className="relative w-full max-w-6xl aspect-16/8 rounded-md border bg-muted"
onClick={(e) => e.stopPropagation()}
>
<Image
@@ -103,10 +259,11 @@ function Partner({ partner, overview }: PartnerData) {
</motion.div>
)}
</AnimatePresence>
<DefaultLayout>
<SectionContainer>
<div className="col-span-12 mx-auto mb-2 max-w-5xl lg:col-span-2">
{/* Back button */}
{/* Back */}
<Link
href="/partners/catalog"
className="text-foreground hover:text-foreground-lighter flex cursor-pointer items-center transition-colors"
@@ -115,13 +272,14 @@ function Partner({ partner, overview }: PartnerData) {
Back
</Link>
{/* Partner header */}
<div className="flex mt-6 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center space-x-4">
<Image
layout="fixed"
width={56}
height={56}
className="bg-surface-200 shrink-f0 h-14 w-14 rounded-full border"
className="bg-surface-200 shrink-0 h-14 w-14 rounded-full border"
src={partner.logo}
alt={partner.title}
/>
@@ -129,122 +287,75 @@ function Partner({ partner, overview }: PartnerData) {
{partner.title}
</h1>
</div>
{partner.installUrl && (
{!isMulti && activeTab.installUrl && (
<Button asChild size="medium">
<a href={partner.installUrl} target="_blank" rel="noreferrer">
<a href={activeTab.installUrl} target="_blank" rel="noreferrer">
Add integration
</a>
</Button>
)}
</div>
<div
className="bg-linear-to-t from-background-alternative to-background border-b p-6 [&_.swiper]:!overflow-visible [&_.swiper-wrapper]:!overflow-visible"
style={{ marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' }}
>
{(partner.images?.length ?? 0) > 0 && (
<SectionContainer className="py-0! px-3! lg:px-12! xl:p-0! mx-auto max-w-5xl mt-32">
<Swiper
initialSlide={0}
spaceBetween={20}
slidesPerView={4}
speed={300}
grabCursor
centeredSlides={false}
centerInsufficientSlides={false}
breakpoints={{
320: {
slidesPerView: 1.15,
centeredSlides: false,
spaceBetween: 10,
},
720: {
slidesPerView: 1.75,
centeredSlides: false,
spaceBetween: 10,
},
920: {
slidesPerView: 2.5,
centeredSlides: false,
},
1024: {
slidesPerView: 3,
},
1280: {
slidesPerView: 4,
},
}}
>
{partner.images?.map((image: any, i: number) => {
return (
<SwiperSlide key={i}>
<div className="relative aspect-[16/8]">
<AnimatePresence>
{focusedImage !== image && (
<motion.div
key={`thumb-${image}`}
layoutId={`partner-image-${image}`}
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
className="absolute inset-0 cursor-zoom-in rounded-md border bg-muted"
onClick={() => setFocusedImage(image)}
>
<Image
placeholder="blur"
blurDataURL="/images/blur.png"
fill
sizes="(min-width: 1280px) 25vw, (min-width: 1024px) 33vw, (min-width: 920px) 40vw, (min-width: 720px) 60vw, 90vw"
src={image}
alt={partner.title}
className="rounded-md object-cover"
/>
</motion.div>
)}
</AnimatePresence>
</div>
</SwiperSlide>
)
})}
</Swiper>
</SectionContainer>
)}
</div>
<div className="grid gap-y-12 lg:grid-cols-8 lg:gap-x-20 mt-16">
{isNarrow && <PartnerDetails partner={partner} />}
<div className="lg:col-span-5 overflow-hidden">
<h2
className="text-foreground"
style={{ fontSize: '1.5rem', marginBottom: '1rem' }}
{/* Tabs (multi-listing only) */}
{isMulti ? (
<Tabs_Shadcn_
value={activeTab.slug}
onValueChange={(slug) => setActiveTabSlug(slug === listings[0].slug ? null : slug)}
className="mt-8"
>
{/* Full-bleed border — mirrors the SectionContainer structure so tabs align with content */}
<div
className="border-b"
style={{ marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' }}
>
Overview
</h2>
<div className="prose">
{'error' in overview ? (
<p>Error rendering integration page: {overview.error.message}</p>
) : (
// @ts-expect-error: Gildas - This is because CodeHide put its components under the CH namespace. It works but the MDXClient types are stricter
<MDXClient {...overview} components={mdxComponents(setFocusedImage)} />
)}
<div className="container mx-auto px-6 lg:px-16 xl:px-20">
<div className="max-w-5xl mx-auto">
<TabsList_Shadcn_ className="w-full justify-start rounded-none bg-transparent h-auto p-0 gap-0">
{listings.map((listing) => (
<TabsTrigger_Shadcn_
key={listing.slug}
value={listing.slug}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-1 text-foreground-light data-[state=active]:text-foreground"
>
{listing.name}
</TabsTrigger_Shadcn_>
))}
</TabsList_Shadcn_>
</div>
</div>
</div>
</div>
{!isNarrow && <PartnerDetails partner={partner} />}
</div>
{partner.installUrl && (
<div className="bg-background hover:border-default-control border-default rounded-2xl border p-10 drop-shadow-xs max-w-5xl mx-auto mt-12">
<div className="flex flex-row justify-between">
<h1 className="text-2xl self-center">
Get started with {partner.title} and Supabase.
</h1>
<a href={partner.installUrl} target="_blank" rel="noreferrer">
<Button size="medium" type="secondary">
Add integration
</Button>
</a>
</div>
</div>
{listings.map((listing) => (
<TabsContent_Shadcn_ key={listing.slug} value={listing.slug}>
{listing.type === 'guide' ? (
<GuideTab
listing={listing}
overview={overview}
focusedImage={focusedImage}
setFocusedImage={setFocusedImage}
partner={partner}
isNarrow={isNarrow}
/>
) : (
<DashboardIntegrationTab
listing={listing}
partner={partner}
isNarrow={isNarrow}
/>
)}
</TabsContent_Shadcn_>
))}
</Tabs_Shadcn_>
) : (
// Single listing: render content directly, no tabs
<GuideTab
listing={activeTab}
overview={overview}
focusedImage={focusedImage}
setFocusedImage={setFocusedImage}
partner={partner}
isNarrow={isNarrow}
/>
)}
</div>
</SectionContainer>
@@ -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 <Error404 />
return (
<div className="lg:col-span-3">
<div className="sticky top-20 flex flex-col gap-4">
<h2 className="text-foreground" style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>
Details
</h2>
<NuqsAdapter>
<PartnerInner partner={partner} overview={overview} />
</NuqsAdapter>
)
}
{partner.youtubeId && (
<ExpandableVideo
videoId={partner.youtubeId}
// imgUrl={videoThumbnail}
imgOverlayText="Watch an introductory video"
triggerContainerClassName="w-full"
/>
// ─── Details sidebar ─────────────────────────────────────────────────────────
const PartnerDetails = ({ partner }: { partner: Partner }) => (
<div className="lg:col-span-3">
<div className="sticky top-20 flex flex-col gap-4">
<h2 className="text-foreground" style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>
Details
</h2>
{partner.youtubeId && (
<ExpandableVideo
videoId={partner.youtubeId}
imgOverlayText="Watch an introductory video"
triggerContainerClassName="w-full"
/>
)}
<div className="text-foreground divide-y">
{partner.type === 'technology' && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Developer</span>
<span className="text-foreground">{partner.partnerName}</span>
</div>
)}
<div className="text-foreground divide-y">
{partner.type === 'technology' && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Developer</span>
<span className="text-foreground">{partner.partnerName}</span>
</div>
)}
{partner.categories.map((category) => (
<div key={category.slug} className="flex items-center justify-between py-2">
<span className="text-lighter">Category</span>
<Link
href={`/partners/catalog#${category.slug}`}
className="text-brand-link hover:underline transition-colors"
>
{category.name}
</Link>
</div>
))}
{partner.categories.map((category) => (
<div key={category.slug} className="flex items-center justify-between py-2">
<span className="text-lighter">Category</span>
<Link
href={`/partners/catalog#${category.slug}`}
className="text-brand-link hover:underline transition-colors"
>
{category.name}
</Link>
</div>
))}
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Website</span>
<a
href={partner.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
{new URL(partner.websiteUrl).host}
</a>
</div>
{partner.type === 'technology' && partner.docsUrl && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Website</span>
<span className="text-foreground-lighter">Documentation</span>
<a
href={partner.websiteUrl}
href={partner.docsUrl}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
{new URL(partner.websiteUrl).host}
<span className="flex items-center space-x-1">
<span>Learn</span>
<ExternalLink width={14} height={14} />
</span>
</a>
</div>
{partner.type === 'technology' && partner.docsUrl && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Documentation</span>
<a
href={partner.docsUrl}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
<span className="flex items-center space-x-1">
<span>Learn</span>
<ExternalLink width={14} height={14} />
</span>
</a>
</div>
)}
</div>
<p className="text-foreground-light text-sm">
Third-party integrations and docs are managed by Supabase partners.
</p>
)}
</div>
</div>
)
}
// This function gets called at build time
<p className="text-foreground-light text-sm">
Third-party integrations and docs are managed by Supabase partners.
</p>
</div>
</div>
)
// ─── 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<PartnerData> = 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<PartnerData> = async ({ params }) =>
autoImport: false,
}
// Parse markdown
const overview = await serialize({
source: partner.content,
options: {
@@ -379,10 +478,7 @@ export const getStaticProps: GetStaticProps<PartnerData> = async ({ params }) =>
},
})
return {
props: { partner, overview },
revalidate: 1800, // 30 minutes
}
return { props: { partner, overview }, revalidate: 1800 }
}
export default Partner
+18
View File
@@ -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,