mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 13:49:09 +03:00
update partner detail with multiple listings
This commit is contained in:
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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user