move all partner catalog pages to app router

This commit is contained in:
Francesco Sansalvadore committed 2026-05-29 14:10:18 +02:00
1 parent 4963226a3b
commit c2ca2e905e
6 files changed
+125 -135

No files matched your search

+21
View File
@@ -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<Params> }) {
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}`)
}
@@ -1,32 +1,25 @@
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
'use client'
import { CH } from '@code-hike/mdx/components'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeft, ExternalLink } from 'lucide-react'
import { type GetStaticPaths, type GetStaticProps } from 'next'
import type { SerializeResult as MDXRemoteSerializeResult } from 'next-mdx-remote-client'
import { MDXClient } from 'next-mdx-remote-client/csr'
import { serialize } from 'next-mdx-remote-client/serialize'
import { NextSeo } from 'next-seo'
import Image from 'next/image'
import Link from 'next/link'
import { useState, type Dispatch, type SetStateAction } from 'react'
import remarkGfm from 'remark-gfm'
import 'swiper/css'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import { getCatalogPartner, listCatalogPartnerSlugs } from '~/lib/marketplaceDb'
import { type ListingDetail, 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, cn } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import Error404 from '../../404'
function mdxComponents(callback: Dispatch<SetStateAction<string | null>>) {
return {
CH,
@@ -37,20 +30,16 @@ function mdxComponents(callback: Dispatch<SetStateAction<string | null>>) {
}
}
type PartnerData = {
type Props = {
partner: Partner
serializedListings: MDXRemoteSerializeResult<Record<string, unknown>, Record<string, unknown>>[]
}
function PartnerPage({ partner, serializedListings }: PartnerData) {
export default function PartnerCatalogDetail({ partner, serializedListings }: Props) {
const [focusedImage, setFocusedImage] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState(0)
const isNarrow = useBreakpoint('lg')
if (!partner) return <Error404 />
// For marketplace DB, partner.listings has per-listing content.
// For legacy DB, synthesise a single tab from the top-level partner data.
const allListings: ListingDetail[] = partner.listings?.length
? partner.listings
: [
@@ -68,20 +57,11 @@ function PartnerPage({ partner, serializedListings }: PartnerData) {
const safeTab = Math.min(activeTab, allListings.length - 1)
const activeListing = allListings[safeTab]
const activeOverview = serializedListings[safeTab] ?? serializedListings[0]
// Prefer the Supabase dashboard integration URL for marketplace integrations.
const installHref = activeListing.dashboardUrl ?? activeListing.installUrl
return (
<>
<NextSeo
title={`${partner.title} | Works With Supabase`}
description={partner.description}
openGraph={{
title: `${partner.title} | Works With Supabase`,
description: partner.description,
url: `https://supabase.com/partners/catalog/${partner.slug}`,
images: [{ url: partner.images?.[0] ?? partner.logo }],
}}
/>
<AnimatePresence>
{focusedImage && (
<motion.div
@@ -96,7 +76,7 @@ function PartnerPage({ partner, serializedListings }: 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
@@ -138,9 +118,9 @@ function PartnerPage({ partner, serializedListings }: PartnerData) {
{partner.title}
</h1>
</div>
{activeListing.installUrl && (
{installHref && (
<Button asChild size="medium">
<a href={activeListing.installUrl} target="_blank" rel="noreferrer">
<a href={installHref} target="_blank" rel="noreferrer">
Add integration
</a>
</Button>
@@ -254,13 +234,13 @@ function PartnerPage({ partner, serializedListings }: PartnerData) {
{!isNarrow && <PartnerDetails partner={partner} activeListing={activeListing} />}
</div>
{activeListing.installUrl && (
{installHref && (
<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={activeListing.installUrl} target="_blank" rel="noreferrer">
<a href={installHref} target="_blank" rel="noreferrer">
<Button size="medium" type="secondary">
Add integration
</Button>
@@ -275,13 +255,13 @@ function PartnerPage({ partner, serializedListings }: PartnerData) {
)
}
const PartnerDetails = ({
function PartnerDetails({
partner,
activeListing,
}: {
partner: Partner
activeListing: ListingDetail
}) => {
}) {
return (
<div className="lg:col-span-3">
<div className="sticky top-20 flex flex-col gap-4">
@@ -368,63 +348,3 @@ const PartnerDetails = ({
</div>
)
}
export const getStaticPaths: GetStaticPaths = async () => {
const slugs = await listCatalogPartnerSlugs()
const paths = slugs?.map((slug) => ({ params: { slug } })) ?? []
return { paths, fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps<PartnerData> = async ({ params }) => {
const partner = await getCatalogPartner(params!.slug as string)
if (!partner) {
return { notFound: true }
}
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
lineNumbers: true,
showCopyButton: true,
skipLanguages: [],
autoImport: false,
}
// For marketplace DB, partner.listings has all per-listing content.
// For legacy DB, fall back to a single synthetic listing from partner-level data.
const listingsForTabs: ListingDetail[] = partner.listings?.length
? partner.listings
: [
{
slug: partner.slug,
label: 'Overview',
content: partner.content,
installUrl: partner.installUrl,
docsUrl: partner.docsUrl,
images: partner.images,
youtubeId: partner.youtubeId,
},
]
const serializedListings = await Promise.all(
listingsForTabs.map((listing) =>
serialize({
source: listing.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]],
},
},
})
)
)
return {
props: { partner, serializedListings },
revalidate: 1800,
}
}
export default PartnerPage
@@ -0,0 +1,82 @@
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
import { getCatalogPartner, listCatalogPartnerSlugs } from '~/lib/marketplaceDb'
import type { ListingDetail } from '~/types/partners'
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
import type { Metadata } from 'next'
import { serialize } from 'next-mdx-remote-client/serialize'
import { notFound } from 'next/navigation'
import { cache } from 'react'
import remarkGfm from 'remark-gfm'
import PartnerCatalogDetail from './PartnerCatalogDetail'
export const revalidate = 1800
type Params = { slug: string }
// Deduplicate the DB call across generateMetadata and the page component.
const getPartner = cache(getCatalogPartner)
export async function generateStaticParams() {
const slugs = await listCatalogPartnerSlugs()
return slugs.map((slug) => ({ slug }))
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params
const partner = await getPartner(slug)
if (!partner) return {}
return {
title: `${partner.title} | Works With Supabase`,
description: partner.description,
openGraph: {
title: `${partner.title} | Works With Supabase`,
description: partner.description,
url: `https://supabase.com/partners/catalog/${partner.slug}`,
images: [{ url: partner.images?.[0] ?? partner.logo }],
},
}
}
export default async function PartnerCatalogPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
const partner = await getPartner(slug)
if (!partner) notFound()
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
lineNumbers: true,
showCopyButton: true,
skipLanguages: [],
autoImport: false,
}
const listingsForTabs: ListingDetail[] = partner.listings?.length
? partner.listings
: [
{
slug: partner.slug,
label: 'Overview',
content: partner.content,
installUrl: partner.installUrl,
docsUrl: partner.docsUrl,
images: partner.images,
youtubeId: partner.youtubeId,
},
]
const serializedListings = await Promise.all(
listingsForTabs.map((listing) =>
serialize({
source: listing.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]],
},
},
})
)
)
return <PartnerCatalogDetail partner={partner} serializedListings={serializedListings} />
}
+8 -2
View File
@@ -165,8 +165,11 @@ async function getPartnersFromMarketplace(): Promise<Partner[]> {
*/
function getLabelForListing(listing: Listing): string {
if (listing.publish_dashboard) return 'Marketplace Integration'
const t = listing.title.toLowerCase()
if (t.includes('foreign data wrapper') || /\bfdw\b/.test(t)) return 'Foreign Data Wrapper'
const isFdw = listing.categories?.some(
(c) =>
c.name.toLowerCase().includes('foreign data wrapper') || c.slug.toLowerCase().includes('fdw')
)
if (isFdw) return 'Foreign Data Wrapper'
if (listing.marketplace_url) return 'Integration'
return 'Guide'
}
@@ -211,6 +214,9 @@ async function getPartnerFromMarketplace(slug: string): Promise<Partner | null>
label: getLabelForListing(listing),
content: listing.content,
installUrl: listing.marketplace_url ?? null,
dashboardUrl: listing.publish_dashboard
? `https://supabase.com/dashboard/project/_/integrations/${listing.slug}/overview`
: null,
docsUrl: listing.documentation_url || null,
images: listing.images?.map(fullImageUrl) ?? [],
youtubeId: listing.youtube_id ?? null,
-40
View File
@@ -1,40 +0,0 @@
import supabase from '~/lib/supabaseMisc'
import { GetStaticPaths, GetStaticProps } from 'next'
import Error404 from '../404'
function PartnerPage() {
// Existing short partner URLs redirect to their current integration pages.
return <Error404 />
}
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: 'blocking',
}
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
let { data: partner } = await supabase
.from('partners')
.select('*')
.eq('approved', true)
.eq('slug', params!.slug as string)
.single()
if (!partner || partner.type === 'expert') {
return {
notFound: true,
}
}
return {
redirect: {
permanent: false,
destination: `/partners/catalog/${partner.slug}`,
},
}
}
export default PartnerPage
+1
View File
@@ -12,6 +12,7 @@ export type ListingDetail = {
label: string
content: string
installUrl: string | null
dashboardUrl?: string | null
docsUrl: string | null
images: string[]
youtubeId: string | null