mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(www): Partner Catalog update (#46757)
## Info architecture change around "Partners" The www "integrations" now become more partner-driven. `/partners/integrations` -> now Partner Catalog under `/partners/catalog` (old links redirect to new paths) Moved them close together in the nav dropdown and in the footer <img width="494" height="336" alt="Screenshot 2026-07-09 at 11 06 41" src="https://github.com/user-attachments/assets/a875fef0-0ab8-47ca-8756-d658b27c4892" /> <img width="1149" height="665" alt="Screenshot 2026-07-09 at 11 09 48" src="https://github.com/user-attachments/assets/9631bb72-fe25-4fb4-b1af-9f14a37d02e7" /> ## /partners This page remains untouched in this PR, updates to layout, content and intake form are delegated to #47874 ## /partners/catalog Listed in the [catalog](https://zone-www-dot-com-git-feat-www-partners-pages-supabase.vercel.app/partners/catalog) are now partners. Some partners match with a listing. <img width="1207" height="866" alt="Screenshot 2026-07-09 at 11 14 17" src="https://github.com/user-attachments/assets/b65216be-976f-4ef5-91f8-1ad49da87b45" /> ## /partners/catalog/[partner] Each partner can have one or more "listings" which are either - simple guides - foreign data wrappers - dashboard integrations Integrations available in the dashboard now all have a prominent "Install integration" cta to open it in the dashboard [integrations page](https://supabase.com/dashboard/project/_/integrations). <img width="1269" height="776" alt="Screenshot 2026-07-09 at 11 16 51" src="https://github.com/user-attachments/assets/3c7bb715-ffce-4d0a-905f-9a660c3b1f5a" /> ## Docs Update docs → [Preview](https://docs-git-feat-www-partners-pages-supabase.vercel.app/docs/guides/integrations) - remove "Supabase marketplace" - use "Dashboard Integrations and Partner Catalog - update integrations in sidenav to link to updated /partners/catalog/** listings <img width="1520" height="696" alt="Screenshot 2026-07-15 at 12 54 47" src="https://github.com/user-attachments/assets/9f5a2794-4536-4299-97df-9732d3d75b4c" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a Partner Catalog experience with search, category filters, official-partner toggle, responsive filtering (sidebar + bottom sheet), grid/list views, and featured partners. * Added Partner Catalog detail pages with tabbed listings, MDX-rendered content, image gallery with zoom overlay, and “add/install” actions. * **Improvements** * Updated “Become a Partner” layout and form support for prefilled values and checkbox-group fields (including validation). * Updated navigation/footer/docs and partner tile links to use Partner Catalog routes; expanded redirects from legacy integrations paths. * Added public agent-skills discovery manifest. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alan Daniel <stylesshjs@gmail.com> Co-authored-by: Alex Hall <alex.hall@supabase.io> Co-authored-by: Miranda Limonczenko <miranda.limonczenko@supabase.io>
This commit is contained in:
41 files changed
+1890
-920
No files matched your search
@@ -7,16 +7,20 @@ NEXT_PUBLIC_BASE_PATH="/docs"
|
||||
|
||||
# Whether to enable features available only on Supabase's own hosted services
|
||||
# Setting this to true requires certain secret keys
|
||||
NEXT_PUBLIC_IS_PLATFORM="false"
|
||||
NEXT_PUBLIC_IS_PLATFORM="true"
|
||||
|
||||
# Base URL for the hosted MCP server. Overrides the DEFAULT_MCP_URL_PLATFORM
|
||||
# fallback in @ui-patterns/McpUrlBuilder.
|
||||
NEXT_PUBLIC_MCP_URL="http://localhost:8080/mcp"
|
||||
|
||||
# Supabase project containing integration information
|
||||
# Supabase project containing integration information (legacy)
|
||||
NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co"
|
||||
NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M"
|
||||
|
||||
# Supabase project backing the Partner Catalog / Dashboard Integrations Marketplace
|
||||
NEXT_PUBLIC_MARKETPLACE_API_URL="https://fgxbxpvumhvzrhqngsyu.supabase.co"
|
||||
NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6"
|
||||
|
||||
# Supabase project containing docs content information
|
||||
NEXT_PUBLIC_SUPABASE_URL="https://xguihxuzqibwxjnimxev.supabase.co"
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTgzNzc1MTgsImV4cCI6MjAzMzk1MzUxOH0.aIqjQ9V7djMxYit-DT1fYNV_VWMHSqldh_18XfX2_BE"
|
||||
NEXT_PUBLIC_MARKETPLACE_API_URL="https://otqhrpbxhxkrhrnjqbba.supabase.co/"
|
||||
NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_ZVVKKu1s88KsSBWVYlou-g_phb2OJVQ"
|
||||
@@ -1,9 +1,14 @@
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { type NavMenuSection } from '~/components/Navigation/Navigation.types'
|
||||
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
|
||||
import Layout from '~/layouts/guides'
|
||||
import { supabaseMisc } from '~/lib/supabaseMisc'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import {
|
||||
createMarketplaceClient,
|
||||
SUPABASE_LISTING_OVERRIDES,
|
||||
SUPABASE_PARTNER_SLUG,
|
||||
type CatalogListing,
|
||||
} from 'common/marketplace-client'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
|
||||
const additionalNavItems = { integrations: await getPartners() }
|
||||
@@ -16,26 +21,82 @@ export default async function IntegrationsLayout({ children }: { children: React
|
||||
const getPartners = unstable_cache(getPartnersImpl, [], {
|
||||
tags: [REVALIDATION_TAGS.PARTNERS],
|
||||
})
|
||||
|
||||
async function getPartnersImpl() {
|
||||
if (!IS_PLATFORM) return []
|
||||
|
||||
const { data, error } = await supabaseMisc()
|
||||
.from('partners')
|
||||
.select('slug, title')
|
||||
.eq('approved', true)
|
||||
.eq('type', 'technology')
|
||||
.order('title')
|
||||
const marketplaceClient = createMarketplaceClient()
|
||||
|
||||
const [{ data: partners, error }, { data: listings }] = await Promise.all([
|
||||
marketplaceClient.from('partners').select('slug, name, type'),
|
||||
marketplaceClient.from('catalog_listings').select('*'),
|
||||
])
|
||||
if (error) {
|
||||
console.error(new Error('Error fetching partners', { cause: error }))
|
||||
}
|
||||
|
||||
const partnerNavItems = (data ?? []).map(
|
||||
(partner) =>
|
||||
({
|
||||
name: partner.title,
|
||||
url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`,
|
||||
}) as Partial<NavMenuSection>
|
||||
)
|
||||
// Only "technology" partners belong in this list, not agencies/experts.
|
||||
const technologyPartnerSlugs = new Set<string>()
|
||||
const partnerNameBySlug = new Map<string, string>()
|
||||
for (const partner of partners ?? []) {
|
||||
if (!partner.slug) continue
|
||||
partnerNameBySlug.set(partner.slug, partner.name ?? partner.slug)
|
||||
if (partner.type === 'technology') technologyPartnerSlugs.add(partner.slug)
|
||||
}
|
||||
|
||||
// Supabase-owned listings still get their own nav item, at their clean catalog page slug.
|
||||
const supabaseOwnedNavItems: Partial<NavMenuSection>[] = []
|
||||
const listingsByPartnerSlug = new Map<string, CatalogListing[]>()
|
||||
|
||||
for (const listing of listings ?? []) {
|
||||
if (!listing.slug) continue
|
||||
|
||||
const override = SUPABASE_LISTING_OVERRIDES[listing.slug]
|
||||
if (override) {
|
||||
supabaseOwnedNavItems.push({
|
||||
name: listing.title || override.name,
|
||||
url: `https://supabase.com/partners/catalog/${override.slug}` as `https://${string}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const partnerSlug = listing.partner_slug
|
||||
if (
|
||||
!partnerSlug ||
|
||||
partnerSlug === SUPABASE_PARTNER_SLUG ||
|
||||
!technologyPartnerSlugs.has(partnerSlug)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const partnerListings = listingsByPartnerSlug.get(partnerSlug) ?? []
|
||||
partnerListings.push(listing)
|
||||
listingsByPartnerSlug.set(partnerSlug, partnerListings)
|
||||
}
|
||||
|
||||
// One nav item per listing (a partner with multiple listings — e.g. an FDW plus a
|
||||
// Dashboard Integration — gets one entry per listing), linking straight to the tab
|
||||
// that listing occupies on its Partner Catalog page.
|
||||
const partnerNavItems: Partial<NavMenuSection>[] = [
|
||||
...Array.from(listingsByPartnerSlug.entries()).flatMap(([partnerSlug, partnerListings]) => {
|
||||
const partnerName = partnerNameBySlug.get(partnerSlug) ?? partnerSlug
|
||||
const catalogUrl = `https://supabase.com/partners/catalog/${partnerSlug}`
|
||||
|
||||
// Show only listing name if partner has one listing
|
||||
if (partnerListings.length === 1) {
|
||||
const listingName = partnerListings[0].title || partnerName
|
||||
return [{ name: listingName, url: catalogUrl as `https://${string}` }]
|
||||
}
|
||||
|
||||
// otherwise show listing name (or slug if no title) plus partner name.
|
||||
return partnerListings.map((listing) => ({
|
||||
name: `${listing.title || listing.slug} (${partnerName})`,
|
||||
url: `${catalogUrl}?tab=${listing.slug}` as `https://${string}`,
|
||||
}))
|
||||
}),
|
||||
...supabaseOwnedNavItems,
|
||||
]
|
||||
partnerNavItems.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
|
||||
return partnerNavItems
|
||||
}
|
||||
@@ -146,8 +146,8 @@ const navCommands = [
|
||||
},
|
||||
{
|
||||
id: 'nav-integrations',
|
||||
name: 'Go to Integrations',
|
||||
route: 'https://supabase.com/partners/integrations',
|
||||
name: 'Go to Partner Catalog',
|
||||
route: 'https://supabase.com/partners/catalog',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('integrations:partners'),
|
||||
},
|
||||
|
||||
@@ -3233,12 +3233,16 @@ export const integrations: NavMenuConstant = {
|
||||
url: '/guides/integrations',
|
||||
},
|
||||
{
|
||||
name: 'Vercel Marketplace',
|
||||
url: '/guides/integrations/vercel-marketplace',
|
||||
name: 'Dashboard Integrations',
|
||||
url: '/guides/integrations#dashboard-integrations',
|
||||
},
|
||||
{
|
||||
name: 'Supabase Marketplace',
|
||||
url: '/guides/integrations/supabase-marketplace',
|
||||
name: 'Partner Catalog',
|
||||
url: '/guides/integrations/partner-catalog',
|
||||
},
|
||||
{
|
||||
name: 'Vercel Marketplace',
|
||||
url: '/guides/integrations/vercel-marketplace',
|
||||
},
|
||||
{
|
||||
name: 'Build Your Own',
|
||||
|
||||
@@ -4,10 +4,14 @@ title: Integrations
|
||||
|
||||
Supabase integrates with many of your favorite third-party services.
|
||||
|
||||
## Dashboard Integrations
|
||||
|
||||
Install and manage extensions, wrappers, and Postgres Modules directly into your project in a couple of clicks. [Browse Dashboard Integrations](/dashboard/project/_/integrations)
|
||||
|
||||
## Partner Catalog
|
||||
|
||||
Browse a curated list of Partners who offer one-click integrations, guides or other ways to extend your Supabase project. [Browse the Partner Catalog](/partners/catalog)
|
||||
|
||||
## Vercel Marketplace
|
||||
|
||||
Create and manage your Supabase projects directly through Vercel. [Get started with Vercel](/docs/guides/integrations/vercel-marketplace).
|
||||
|
||||
## Supabase Marketplace
|
||||
|
||||
Browse tools for extending your Supabase project. [Browse the Supabase Marketplace](/partners/integrations).
|
||||
+5
-6
@@ -1,13 +1,12 @@
|
||||
---
|
||||
id: 'integrations'
|
||||
title: 'Supabase Marketplace'
|
||||
title: 'Partner Catalog'
|
||||
description: 'Integrations and Partners'
|
||||
---
|
||||
|
||||
The Supabase Marketplace brings together all the tools you need to extend your Supabase project. This includes:
|
||||
The [Partner Catalog](/partners/catalog) is Supabase's public directory of third-party integrations. It lists tools that extend your Supabase project. These tools cover Auth, Caching, Hosting, and Low-code categories.
|
||||
|
||||
- [Experts](/partners/experts) - partners to help you build and support your Supabase project.
|
||||
- [Integrations](/partners/integrations) - extend your projects with external Auth, Caching, Hosting, and Low-code tools.
|
||||
The Partner Catalog is different from [Dashboard Integrations](/guides/integrations#dashboard-integrations). You install Dashboard Integrations directly from a project in the Supabase Dashboard.
|
||||
|
||||
## Build an integration
|
||||
|
||||
@@ -20,12 +19,12 @@ Supabase provides several integration points:
|
||||
|
||||
## List your integration
|
||||
|
||||
[Apply to the Partners program](/partners/integrations#become-a-partner) to list your integration in the Partners marketplace and in the Supabase docs.
|
||||
[Apply to the Partners program](/partners/catalog#become-a-partner) to list your integration in the Partner Catalog and in the Supabase docs.
|
||||
|
||||
Integrations are assessed on the following criteria:
|
||||
|
||||
- **Business viability**
|
||||
While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the marketplace.
|
||||
While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the catalog.
|
||||
- **Compliance**
|
||||
Integrations should not infringe on the Supabase brand/trademark. In short, you cannot use "Supabase" in the name. As the listing appears on the Supabase domain, we don't want to mislead developers into thinking that an integration is an official product.
|
||||
- **Service Level Agreements**
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
let _supabaseMisc: SupabaseClient
|
||||
|
||||
export function supabaseMisc() {
|
||||
if (!_supabaseMisc) {
|
||||
_supabaseMisc = createClient(
|
||||
process.env.NEXT_PUBLIC_MISC_URL!,
|
||||
process.env.NEXT_PUBLIC_MISC_ANON_KEY!
|
||||
)
|
||||
}
|
||||
|
||||
return _supabaseMisc
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_MISC_URL",
|
||||
"NEXT_PUBLIC_MISC_ANON_KEY",
|
||||
"NEXT_PUBLIC_MARKETPLACE_API_URL",
|
||||
"NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY",
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_API_URL",
|
||||
"NEXT_PUBLIC_BASE_PATH",
|
||||
|
||||
+3
-51
@@ -1,26 +1,4 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Cable,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
Package2,
|
||||
Plug,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Webhook,
|
||||
Wrench,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { BadgeCheck, Cable, Database, Package2, Plug, Users, type LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge, cn, IconPartners } from 'ui'
|
||||
|
||||
@@ -29,6 +7,8 @@ import type {
|
||||
MarketplaceSource,
|
||||
} from '@/components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
|
||||
export { getCategoryIcon, CATEGORY_ICONS } from 'common/marketplace-categories'
|
||||
|
||||
export type { MarketplaceSource } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
|
||||
// Defines featured integrations and their order in the featured hero
|
||||
@@ -47,29 +27,6 @@ export const INTEGRATION_TYPES: Array<{
|
||||
{ key: 'wrapper', label: 'Wrapper', icon: Cable },
|
||||
]
|
||||
|
||||
// Lucide icon per marketplace category slug. New categories fall back to a
|
||||
// neutral icon (Boxes) so nothing breaks when the marketplaceDB is updated.
|
||||
export const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
||||
observability: BarChart3,
|
||||
security: ShieldCheck,
|
||||
billing: CreditCard,
|
||||
secrets: KeyRound,
|
||||
email: Mail,
|
||||
wrappers: Database,
|
||||
ai: Cpu,
|
||||
ai_vectors: Cpu,
|
||||
storage: Package2,
|
||||
postgres_extension: Database,
|
||||
wrapper: Cable,
|
||||
devtools: Wrench,
|
||||
auth: Fingerprint,
|
||||
'low-code': MousePointerClick,
|
||||
'data-platform': Server,
|
||||
api: Webhook,
|
||||
'caching-offline-first': Zap,
|
||||
messaging: MessageSquare,
|
||||
}
|
||||
|
||||
export const FEATURED_CATEGORIES: Array<{ slug: string; name: string }> = [
|
||||
{ slug: 'observability', name: 'Observability' },
|
||||
{ slug: 'security', name: 'Security' },
|
||||
@@ -87,11 +44,6 @@ export const EXCLUDED_CATEGORY_SLUGS = new Set([
|
||||
'app-templates',
|
||||
])
|
||||
|
||||
export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => {
|
||||
if (!slug) return Boxes
|
||||
return CATEGORY_ICONS[slug] ?? Boxes
|
||||
}
|
||||
|
||||
export const formatCategoryLabel = (
|
||||
slug: string | null | undefined,
|
||||
categoryOptions?: Array<{ slug: string; name: string }>
|
||||
|
||||
@@ -229,7 +229,7 @@ export const MarketplaceIndex = () => {
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<DocsButton href={`${DOCS_URL}/guides/integrations/supabase-marketplace`} />
|
||||
<DocsButton href={`${DOCS_URL}/guides/integrations/partner-catalog`} />
|
||||
</div>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import Panel from '~/components/Panel'
|
||||
import { searchCatalogPartners } from '~/lib/marketplaceDb'
|
||||
import type { Partner } from '~/types/partners'
|
||||
import { getCategoryIcon } from 'common/marketplace-categories'
|
||||
import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll'
|
||||
import { ArrowRight, ArrowUpRight, Filter, LayoutGrid, List, Loader, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsBoolean,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryStates,
|
||||
} from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
cn,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from 'ui'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
interface Props {
|
||||
initialPartners: Partner[]
|
||||
metaTitle: string
|
||||
metaDescription: string
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'list'
|
||||
|
||||
const OFFICIAL_PARTNER_SLUGS = new Set(['grafana', 'stripe', 'aikido', 'doppler', 'resend'])
|
||||
const PARTNERS_PAGE_SIZE = 24
|
||||
|
||||
export default function IntegrationsContent({
|
||||
initialPartners,
|
||||
metaTitle,
|
||||
metaDescription,
|
||||
}: Props) {
|
||||
const [partners, setPartners] = useState(initialPartners)
|
||||
// Deduplicate by slug, keep name for display, sort alphabetically.
|
||||
const allCategories = Array.from(
|
||||
new Map(initialPartners?.flatMap((p) => p.categories).map((c) => [c.slug, c]) ?? []).values()
|
||||
).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||
|
||||
const [{ q: search, cat: selectedCategories, partner: partnerOnly, view: viewMode }, setFilters] =
|
||||
useQueryStates(
|
||||
{
|
||||
q: parseAsString.withDefault(''),
|
||||
cat: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
partner: parseAsBoolean.withDefault(false),
|
||||
view: parseAsStringEnum<ViewMode>(['grid', 'list']).withDefault('grid'),
|
||||
},
|
||||
{ history: 'replace' }
|
||||
)
|
||||
|
||||
const [debouncedSearchTerm] = useDebounce(search, 300)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const searchIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm.trim() === '') {
|
||||
setIsSearching(false)
|
||||
setPartners(initialPartners)
|
||||
return
|
||||
}
|
||||
setIsSearching(true)
|
||||
const currentSearchId = ++searchIdRef.current
|
||||
searchCatalogPartners(debouncedSearchTerm)
|
||||
.then((results) => {
|
||||
if (currentSearchId === searchIdRef.current) setPartners(results ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (currentSearchId === searchIdRef.current) setPartners([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (currentSearchId === searchIdRef.current) setIsSearching(false)
|
||||
})
|
||||
}, [debouncedSearchTerm, initialPartners])
|
||||
|
||||
// selectedCategories holds slugs; toggle by slug
|
||||
const handleCategoryChange = (slug: string) =>
|
||||
setFilters({
|
||||
cat: selectedCategories.includes(slug)
|
||||
? selectedCategories.filter((s) => s !== slug)
|
||||
: [...selectedCategories, slug],
|
||||
})
|
||||
|
||||
const HAS_ACTIVE_FILTERS = search.trim() !== '' || selectedCategories.length > 0 || partnerOnly
|
||||
const activeFilterCount = selectedCategories.length + (partnerOnly ? 1 : 0)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const categoryFiltered =
|
||||
selectedCategories.length > 0
|
||||
? partners.filter((p) => p.categories.some((c) => selectedCategories.includes(c.slug)))
|
||||
: partners
|
||||
|
||||
return partnerOnly
|
||||
? categoryFiltered.filter((p) => OFFICIAL_PARTNER_SLUGS.has(p.slug))
|
||||
: categoryFiltered
|
||||
}, [partners, selectedCategories, partnerOnly])
|
||||
|
||||
const featuredPartners = useMemo(
|
||||
() => filtered.filter((p) => p.featured).sort((a, b) => a.title.localeCompare(b.title)),
|
||||
[filtered]
|
||||
)
|
||||
const listPartners = useMemo(
|
||||
() =>
|
||||
HAS_ACTIVE_FILTERS
|
||||
? [...filtered.filter((p) => p.featured), ...filtered.filter((p) => !p.featured)]
|
||||
: filtered.filter((p) => !p.featured),
|
||||
[filtered, HAS_ACTIVE_FILTERS]
|
||||
)
|
||||
const showFeatured = !HAS_ACTIVE_FILTERS && featuredPartners.length > 0
|
||||
|
||||
// Only render the first page of the (already-fetched, in-memory) partner list up front,
|
||||
// then reveal more as the sentinel below the grid/list scrolls into view — avoids mounting
|
||||
// every partner card (and its logo <Image>) on initial load.
|
||||
const initialVisiblePartners = useMemo(
|
||||
() => listPartners.slice(0, PARTNERS_PAGE_SIZE),
|
||||
[listPartners]
|
||||
)
|
||||
const fetchMoreVisiblePartners = useCallback(
|
||||
async (offset: number, limit: number) => listPartners.slice(offset, offset + limit),
|
||||
[listPartners]
|
||||
)
|
||||
const {
|
||||
items: visiblePartners,
|
||||
hasMore: hasMorePartners,
|
||||
loadMoreRef: partnersLoadMoreRef,
|
||||
} = useInfiniteScrollWithFetch({
|
||||
initialItems: initialVisiblePartners,
|
||||
totalItems: listPartners.length,
|
||||
pageSize: PARTNERS_PAGE_SIZE,
|
||||
fetchMore: fetchMoreVisiblePartners,
|
||||
})
|
||||
|
||||
// Shared filter UI — rendered in both the desktop sidebar and the mobile bottom sheet.
|
||||
// Uses wrapping <label> elements (no id/htmlFor) to avoid duplicate HTML IDs in the DOM.
|
||||
const filtersPanel = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h2 className="text-xs font-mono uppercase text-foreground-lighter">Categories</h2>
|
||||
{allCategories.map((category) => {
|
||||
const Icon = getCategoryIcon(category.slug)
|
||||
return (
|
||||
<label
|
||||
key={category.slug}
|
||||
className="flex cursor-pointer items-center justify-between gap-3 text-foreground-light transition-colors hover:text-foreground"
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-2 text-sm text-left">
|
||||
<Icon size={13} className="shrink-0 text-foreground-lighter" />
|
||||
{category.name}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={selectedCategories.includes(category.slug)}
|
||||
onCheckedChange={() => handleCategoryChange(category.slug)}
|
||||
className="[&_input]:m-0"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{HAS_ACTIVE_FILTERS && (
|
||||
<Button
|
||||
block
|
||||
variant="dashed"
|
||||
onClick={() => setFilters({ cat: [], partner: false, q: '' })}
|
||||
>
|
||||
Clear all filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<SectionContainer>
|
||||
<div className="flex flex-col gap-3 mb-8">
|
||||
<span className="text-brand font-mono uppercase tracking-widest text-sm">
|
||||
Partner Catalog
|
||||
</span>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="h1 mb-0!">{metaTitle}</h1>
|
||||
<p className="text-foreground-lighter text-xl max-w-2xl text-balance">
|
||||
{metaDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="tiny" className="shrink-0">
|
||||
<Link href="https://forms.supabase.com/partner">Apply as a Partner</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative grid md:grid-cols-4 md:gap-4">
|
||||
{/* Left sidebar — sticky search + filters */}
|
||||
<div className="relative w-full h-full">
|
||||
<div className="mb-4 flex flex-col gap-4 sticky top-20">
|
||||
{/* Search bar + mobile filter trigger */}
|
||||
<div className="flex gap-2">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
type="search"
|
||||
placeholder="Search partners"
|
||||
value={search}
|
||||
onChange={(e) => setFilters({ q: e.target.value })}
|
||||
/>
|
||||
{isSearching && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Loader size={14} className="animate-spin" />
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{/* Mobile-only: opens bottom sheet with filters */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="default" size="small" className="shrink-0 md:hidden flex">
|
||||
<div className="flex gap-1.5 items-center text-foreground-lighter hover:text-foreground">
|
||||
<Filter size={14} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex items-center justify-center text-xs text-foreground-light">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" className="flex h-screen max-h-[85dvh] flex-col">
|
||||
<SheetHeader className="px-4 py-3.5">
|
||||
<SheetTitle className="text-sm text-left">Filter partners</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4">{filtersPanel}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop-only filter panel */}
|
||||
<div className="hidden md:block">{filtersPanel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: content */}
|
||||
<div className="md:col-span-3 min-w-0 flex flex-col gap-4 md:gap-6">
|
||||
{/* Featured partners */}
|
||||
{showFeatured && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl">Featured</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{featuredPartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/catalog/${p.slug}`}
|
||||
className="group flex h-full flex-col gap-3 rounded-xl border bg-surface-100 p-5 transition-colors hover:bg-surface-200 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
fill
|
||||
className="object-cover bg-white rounded-full overflow-hidden p-1"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<h3 className="text-foreground text-base font-medium tracking-tight">
|
||||
{p.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-sm line-clamp-3 text-pretty">
|
||||
{p.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{p.categories.map((c) => (
|
||||
<Badge key={c.name}>{c.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar: count + view toggle */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-foreground-muted text-xs">
|
||||
{listPartners.length} partner{listPartners.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex items-center rounded-lg border border-muted">
|
||||
<button
|
||||
title="Grid view"
|
||||
onClick={() => setFilters({ view: 'grid' })}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-8 h-8 transition-colors rounded-l-lg focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
||||
viewMode === 'grid'
|
||||
? 'bg-surface-300 text-foreground'
|
||||
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
title="List view"
|
||||
onClick={() => setFilters({ view: 'list' })}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-8 h-8 transition-colors border-l border-muted rounded-r-lg focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
||||
viewMode === 'list'
|
||||
? 'bg-surface-300 text-foreground'
|
||||
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid view */}
|
||||
{viewMode === 'grid' &&
|
||||
(listPartners.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
|
||||
{visiblePartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/catalog/${p.slug}`}
|
||||
className="flex flex-col justify-start items-stretch group cursor-pointer transition rounded-xl focus-visible:ring-2 focus-visible:ring-foreground-lighter outline-hidden outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-foreground-lighter"
|
||||
>
|
||||
<Panel
|
||||
hasActiveOnHover
|
||||
outerClassName="h-full"
|
||||
innerClassName="flex md:flex-col gap-3 sm:gap-2 h-full items-start p-2"
|
||||
>
|
||||
<div className="relative rounded-lg min-h-[80px] max-h-[80px] md:max-h-[140px] h-full md:h-auto aspect-square md:w-full md:aspect-video! bg-alternative flex items-center justify-center shadow-inner border border-muted overflow-hidden shrink-0 md:shrink">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
width={60}
|
||||
height={60}
|
||||
className="border w-14 h-14 aspect-square p-1 bg-white rounded-full overflow-hidden"
|
||||
sizes="60px"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:p-2 md:pt-1 flex flex-col h-full md:h-auto grow gap-0.5 md:gap-1.5 justify-center md:justify-start min-w-0">
|
||||
<h3 className="text-sm md:text-base text-foreground leading-5!">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="text-foreground-light text-sm line-clamp-2 flex-1">
|
||||
{p.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
{p.categories.map((c) => (
|
||||
<Badge key={c.name}>{c.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
No partners found with these filters.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{/* List view */}
|
||||
{viewMode === 'list' &&
|
||||
(listPartners.length ? (
|
||||
<div className="border bg-background rounded-xl overflow-hidden divide-y">
|
||||
{visiblePartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/catalog/${p.slug}`}
|
||||
className="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-surface-100"
|
||||
>
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
fill
|
||||
className="object-cover bg-white rounded-full overflow-hidden p-1"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-foreground text-sm font-medium tracking-tight">
|
||||
{p.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{p.categories.map((c) => (
|
||||
<Badge key={c.name}>{c.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-sm truncate">{p.description}</p>
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
size={16}
|
||||
className="shrink-0 text-foreground-lighter transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
No partners found with these filters.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{hasMorePartners && (
|
||||
<div
|
||||
ref={partnersLoadMoreRef}
|
||||
className="flex justify-center py-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Loader size={16} className="animate-spin text-foreground-lighter" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
|
||||
<div className="border-t bg-background">
|
||||
<div
|
||||
id="become-a-partner"
|
||||
className="mx-auto max-w-2xl flex flex-col items-center gap-6 py-32 px-6 text-center"
|
||||
>
|
||||
<h2 className="h2 text-balance">Interested in partnering with Supabase?</h2>
|
||||
<Button asChild size="medium" iconRight={<ArrowRight />}>
|
||||
<Link href="https://forms.supabase.com/partner">Apply as a Partner</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
'use client'
|
||||
|
||||
import 'swiper/css'
|
||||
|
||||
import { CH } from '@code-hike/mdx/components'
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import { type ListingDetail, type Partner } from '~/types/partners'
|
||||
import { useBreakpoint } from 'common'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowUpRight, ChevronLeft, ExternalLink } from 'lucide-react'
|
||||
import type { SerializeResult as MDXRemoteSerializeResult } from 'next-mdx-remote-client'
|
||||
import { MDXClient } from 'next-mdx-remote-client/csr'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
|
||||
|
||||
import SectionContainerWithCn from '@/components/Layouts/SectionContainerWithCn'
|
||||
|
||||
function mdxComponents(callback: Dispatch<SetStateAction<string | null>>) {
|
||||
return {
|
||||
CH,
|
||||
Admonition,
|
||||
img: (
|
||||
props: React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
|
||||
) => <img {...props} onClick={() => callback(props.src!.toString())} />,
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
partner: Partner
|
||||
serializedListings: MDXRemoteSerializeResult<Record<string, unknown>, Record<string, unknown>>[]
|
||||
}
|
||||
|
||||
export default function PartnerCatalogDetail({ partner, serializedListings }: Props) {
|
||||
const [focusedImage, setFocusedImage] = useState<string | null>(null)
|
||||
const isNarrow = useBreakpoint('lg')
|
||||
const tabsRef = useRef<HTMLDivElement>(null)
|
||||
const [showStickyBar, setShowStickyBar] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const tabsEl = tabsRef.current
|
||||
if (!tabsEl) return
|
||||
|
||||
// Sticky bar sticks at top-16 (4rem), so show it once the tabs scroll past that point.
|
||||
const handleScroll = () => setShowStickyBar(tabsEl.getBoundingClientRect().bottom <= 64)
|
||||
|
||||
handleScroll()
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Computed before the hook so the first slug can seed the default value.
|
||||
const allListings: ListingDetail[] = partner.listings?.length
|
||||
? partner.listings
|
||||
: [
|
||||
{
|
||||
slug: partner.slug,
|
||||
label: 'Overview',
|
||||
publishedInMarketplace: false,
|
||||
content: partner.content,
|
||||
installUrl: partner.installUrl,
|
||||
docsUrl: partner.docsUrl,
|
||||
images: partner.images,
|
||||
youtubeId: partner.youtubeId,
|
||||
},
|
||||
]
|
||||
|
||||
const [activeSlug, setActiveSlug] = useQueryState('tab', {
|
||||
defaultValue: allListings[0]?.slug ?? '',
|
||||
history: 'replace',
|
||||
})
|
||||
|
||||
const activeListing = allListings.find((l) => l.slug === activeSlug) ?? allListings[0]
|
||||
const activeTabIndex = allListings.indexOf(activeListing)
|
||||
const activeOverview = serializedListings[activeTabIndex] ?? serializedListings[0]
|
||||
// Marketplace integrations link to the Supabase dashboard; others use installUrl if provided.
|
||||
const INSTALL_HREF_BLOCKLIST = ['aikido-security', 'stripe-sync-engine']
|
||||
const installHref = INSTALL_HREF_BLOCKLIST.includes(partner.slug)
|
||||
? undefined
|
||||
: activeListing.publishedInMarketplace
|
||||
? activeListing.dashboardUrl
|
||||
: activeListing.installUrl
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{focusedImage && (
|
||||
<motion.div
|
||||
key="overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: [0.32, 0.72, 0, 1] }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-8 cursor-zoom-out"
|
||||
onClick={() => setFocusedImage(null)}
|
||||
>
|
||||
<motion.div
|
||||
layoutId={`partner-image-${focusedImage}`}
|
||||
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
|
||||
className="relative w-full aspect-16/8 rounded-md border bg-muted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Image
|
||||
src={focusedImage}
|
||||
alt={partner.title}
|
||||
fill
|
||||
sizes="80vw"
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<DefaultLayout>
|
||||
<div className="relative h-auto w-full">
|
||||
<SectionContainerWithCn height="narrow" className="col-span-12 lg:col-span-2">
|
||||
{/* Back button */}
|
||||
<Link
|
||||
href="/partners/catalog"
|
||||
className="text-foreground-lighter hover:text-foreground text-sm flex cursor-pointer items-center transition-colors gap-1"
|
||||
>
|
||||
<ChevronLeft width={14} height={14} />
|
||||
Partner Catalog
|
||||
</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
|
||||
width={56}
|
||||
height={56}
|
||||
className="bg-white p-1 shrink-0 h-14 w-14 rounded-full border"
|
||||
src={partner.logo}
|
||||
alt={partner.title}
|
||||
/>
|
||||
<h1 className="h1 mb-0!">{partner.title}</h1>
|
||||
</div>
|
||||
{installHref && (
|
||||
<Button asChild size="medium" iconRight={<ArrowUpRight strokeWidth={1.5} />}>
|
||||
<a href={installHref} target="_blank" rel="noreferrer">
|
||||
{activeListing.publishedInMarketplace
|
||||
? 'Install integration'
|
||||
: 'Add integration'}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SectionContainerWithCn>
|
||||
<div className="border-b">
|
||||
<SectionContainerWithCn
|
||||
height="none"
|
||||
className="overflow-x-scroll overflow-y-hidden"
|
||||
ref={tabsRef}
|
||||
>
|
||||
{/* Listings tabs */}
|
||||
<div className="flex">
|
||||
{allListings.map((listing, i) => (
|
||||
<button
|
||||
key={listing.slug}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSlug(listing.slug)
|
||||
setFocusedImage(null)
|
||||
}}
|
||||
className={cn(
|
||||
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap hover:cursor-pointer',
|
||||
activeTabIndex === i
|
||||
? 'border-foreground text-foreground'
|
||||
: 'border-transparent text-foreground-lighter hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{listing.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SectionContainerWithCn>
|
||||
</div>
|
||||
|
||||
{/* Images for active listing */}
|
||||
{(activeListing.images?.length ?? 0) > 0 && (
|
||||
<div className="bg-linear-to-t overflow-hidden from-background-alternative to-background border-b py-6 [&_.swiper]:overflow-visible! [&_.swiper-wrapper]:overflow-visible!">
|
||||
<SectionContainerWithCn height="none" className="">
|
||||
<Swiper
|
||||
initialSlide={0}
|
||||
spaceBetween={20}
|
||||
slidesPerView={4}
|
||||
speed={300}
|
||||
grabCursor
|
||||
centeredSlides={false}
|
||||
centerInsufficientSlides={false}
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 1.45, centeredSlides: false, spaceBetween: 10 },
|
||||
720: { slidesPerView: 1.75, centeredSlides: false, spaceBetween: 10 },
|
||||
920: { slidesPerView: 2.5, centeredSlides: false },
|
||||
1024: { slidesPerView: 3 },
|
||||
1280: { slidesPerView: 4 },
|
||||
}}
|
||||
>
|
||||
{activeListing.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>
|
||||
</SectionContainerWithCn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<SectionContainerWithCn height="narrow">
|
||||
<div className="grid gap-y-12 lg:grid-cols-8 lg:gap-x-20">
|
||||
{isNarrow && <PartnerDetails partner={partner} activeListing={activeListing} />}
|
||||
|
||||
<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 activeOverview ? (
|
||||
<p>Error rendering integration page: {activeOverview.error.message}</p>
|
||||
) : (
|
||||
// @ts-expect-error: CodeHike puts its components under the CH namespace
|
||||
<MDXClient {...activeOverview} components={mdxComponents(setFocusedImage)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isNarrow && <PartnerDetails partner={partner} activeListing={activeListing} />}
|
||||
</div>
|
||||
</SectionContainerWithCn>
|
||||
<div className="absolute h-full w-full inset-0 z-20 pointer-events-none">
|
||||
<SectionContainerWithCn
|
||||
height="none"
|
||||
className={cn(
|
||||
'transition-all bg-background/90 -translate-y-3/4 dark:bg-background/95 backdrop-blur-xs border-b sticky top-16 z-20 flex justify-between items-center gap-4 py-2',
|
||||
showStickyBar
|
||||
? 'opacity-100 pointer-events-auto translate-y-0'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image
|
||||
width={56}
|
||||
height={56}
|
||||
className="hidden sm:block bg-white p-0.5 shrink-0 h-6 w-6 rounded-full border"
|
||||
src={partner.logo}
|
||||
alt={partner.title}
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-1">
|
||||
<span className="sm:text-lg truncate">{partner.title}</span>
|
||||
{activeListing && (
|
||||
<>
|
||||
<span className="text-foreground-lighter text-sm hidden sm:inline">—</span>
|
||||
<span className="text-foreground-lighter text-sm truncate">
|
||||
{activeListing.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{installHref && (
|
||||
<Button asChild>
|
||||
<a href={installHref} target="_blank" rel="noreferrer">
|
||||
{activeListing.publishedInMarketplace
|
||||
? 'Install integration'
|
||||
: 'Add integration'}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</SectionContainerWithCn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t bg-background">
|
||||
<SectionContainerWithCn className="mx-auto max-w-2xl flex flex-col items-center gap-6 py-24 px-6 text-center">
|
||||
<h2 className="h2 text-balance">Interested in partnering with Supabase?</h2>
|
||||
<Button asChild size="medium">
|
||||
<Link href="/partners#become-a-partner">Become a partner</Link>
|
||||
</Button>
|
||||
</SectionContainerWithCn>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PartnerDetails({
|
||||
partner,
|
||||
activeListing,
|
||||
}: {
|
||||
partner: Partner
|
||||
activeListing: ListingDetail
|
||||
}) {
|
||||
return (
|
||||
<div className="lg:col-span-3">
|
||||
<div className="sticky top-32 flex flex-col gap-4">
|
||||
<h2 className="text-foreground font-mono uppercase tracking-wide text-xs">Details</h2>
|
||||
|
||||
{activeListing.youtubeId && (
|
||||
<ExpandableVideo
|
||||
videoId={activeListing.youtubeId}
|
||||
imgOverlayText="Watch an introductory video"
|
||||
triggerContainerClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-foreground divide-y text-sm">
|
||||
{partner.type === 'technology' && (
|
||||
<div className="flex items-center justify-between py-1.5 md:py-2">
|
||||
<span className="text-foreground-lighter">Developer</span>
|
||||
<span className="text-foreground">{partner.builtBy}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partner.categories.map((category) => (
|
||||
<div key={category.slug} className="flex items-center justify-between py-1.5 md:py-2">
|
||||
<span className="text-lighter">Category</span>
|
||||
<Link
|
||||
href={`/partners/catalog?cat=${encodeURIComponent(category.slug)}`}
|
||||
className="text-brand-link hover:underline transition-colors"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{partner.websiteUrl && (
|
||||
<div className="flex items-center justify-between py-1.5 md:py-2">
|
||||
<span className="text-foreground-lighter">Website</span>
|
||||
<a
|
||||
href={
|
||||
partner.websiteUrl.startsWith('http')
|
||||
? partner.websiteUrl
|
||||
: `https://${partner.websiteUrl}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand-link hover:underline transition-colors"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const url = partner.websiteUrl.startsWith('http')
|
||||
? partner.websiteUrl
|
||||
: `https://${partner.websiteUrl}`
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return partner.websiteUrl
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partner.type === 'technology' && activeListing.docsUrl && (
|
||||
<div className="flex items-center justify-between py-1.5 md:py-2">
|
||||
<span className="text-foreground-lighter">Documentation</span>
|
||||
<a
|
||||
href={activeListing.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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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, Suspense } 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',
|
||||
publishedInMarketplace: false,
|
||||
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 (
|
||||
<Suspense>
|
||||
<PartnerCatalogDetail partner={partner} serializedListings={serializedListings} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { listCatalogPartners } from '~/lib/marketplaceDb'
|
||||
import type { Metadata } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
import IntegrationsContent from './IntegrationsContent'
|
||||
|
||||
export const revalidate = 1800 // 30 minutes
|
||||
|
||||
const META_TITLE = 'Partners building with Supabase'
|
||||
const META_DESCRIPTION =
|
||||
'Browse companies that build on Supabase, integrate with Supabase, or both.'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: META_TITLE,
|
||||
description: META_DESCRIPTION,
|
||||
openGraph: {
|
||||
title: META_TITLE,
|
||||
description: META_DESCRIPTION,
|
||||
url: 'https://supabase.com/partners/catalog',
|
||||
images: [{ url: 'https://supabase.com/images/og/integrations.png' }],
|
||||
},
|
||||
}
|
||||
|
||||
export default async function IntegrationPartnersPage() {
|
||||
const partners = await listCatalogPartners()
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<IntegrationsContent
|
||||
initialPartners={partners}
|
||||
metaTitle={META_TITLE}
|
||||
metaDescription={META_DESCRIPTION}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
+25
-22
@@ -12,6 +12,7 @@ import {
|
||||
import { WwwCommandMenu } from 'components/CommandMenu'
|
||||
import { DevToolbar, DevToolbarProvider } from 'dev-tools'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { TooltipProvider } from 'ui'
|
||||
import { CommandProvider } from 'ui-patterns/CommandMenu'
|
||||
import { useConsentToast } from 'ui-patterns/consent'
|
||||
@@ -23,28 +24,30 @@ function Providers({ children }: { children: React.ReactNode }) {
|
||||
const { hasAcceptedConsent } = useConsentToast()
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<DevToolbarProvider apiUrl={API_URL}>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<CommandProvider>
|
||||
<TelemetryTagManager />
|
||||
<Toaster />
|
||||
{children}
|
||||
<WwwCommandMenu />
|
||||
<PageTelemetry
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
<DevToolbar />
|
||||
</CommandProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</DevToolbarProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
<NuqsAdapter>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<DevToolbarProvider apiUrl={API_URL}>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<CommandProvider>
|
||||
<TelemetryTagManager />
|
||||
<Toaster />
|
||||
{children}
|
||||
<WwwCommandMenu />
|
||||
<PageTelemetry
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
<DevToolbar />
|
||||
</CommandProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</DevToolbarProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</NuqsAdapter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from 'ui'
|
||||
|
||||
export interface SectionEyebrowProps {
|
||||
eyebrow?: string
|
||||
title: string | React.ReactNode
|
||||
description?: string
|
||||
align?: 'left' | 'center'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SectionHeading = ({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
align = 'left',
|
||||
className,
|
||||
}: SectionEyebrowProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
align === 'center' ? 'flex flex-col items-center text-center gap-3' : 'flex flex-col gap-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{eyebrow && (
|
||||
<span className="text-brand font-mono text-sm uppercase tracking-wide">{eyebrow}</span>
|
||||
)}
|
||||
<h2 className="text-foreground text-3xl md:text-4xl tracking-tight max-w-[35ch] text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-foreground-lighter text-lg max-w-[56ch] text-pretty">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default SectionHeading
|
||||
@@ -82,7 +82,7 @@ const Panel = ({
|
||||
<Component
|
||||
ref={outerRef}
|
||||
className={cn(
|
||||
'group/panel relative rounded-lg md:rounded-xl p-px bg-surface-75 bg-linear-to-b from-border to-border/50 dark:to-surface-100 transition-all hover:shadow-md flex items-center justify-center',
|
||||
'group/panel relative rounded-lg md:rounded-xl p-px bg-surface-75 bg-linear-to-b from-border to-border/50 dark:to-surface-100 transition-all hover:shadow-md',
|
||||
!trackCursor && hasActiveOnHover
|
||||
? activeColor === 'brand'
|
||||
? 'hover:bg-none hover:bg-brand!'
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Button } from 'ui'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function BecomeAPartner() {
|
||||
return (
|
||||
<div className="border-t">
|
||||
<div id="become-a-partner" className="mx-auto max-w-2xl space-y-12 py-32 px-6 text-center">
|
||||
<h2 className="h2">Ready to work together?</h2>
|
||||
<Button asChild size="medium" className="text-white">
|
||||
<Link href="https://forms.supabase.com/partner" as="https://forms.supabase.com/partner">
|
||||
Become a partner
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Category, Partner } from '~/types/partners'
|
||||
import type { Listing } from 'common/marketplace-client'
|
||||
import type { CatalogListing } from 'common/marketplace-client'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function TileGrid({
|
||||
<h2 className="h2">Featured</h2>
|
||||
<div className="grid grid-cols-1 gap-5 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredPartners?.map((p) => (
|
||||
<Link key={p.slug} href={`/partners/integrations/${p.slug}`}>
|
||||
<Link key={p.slug} href={`/partners/catalog/${p.slug}`}>
|
||||
<div
|
||||
className="
|
||||
bg-surface-100
|
||||
@@ -85,7 +85,7 @@ export default function TileGrid({
|
||||
{!hideCategories && <h2 className="h2">{partnersByCategory[slug].category.name}</h2>}
|
||||
<div className="grid grid-cols-1 gap-5 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
|
||||
{partners.map((p) => (
|
||||
<Link key={p.slug} href={`/partners/integrations/${p.slug}`}>
|
||||
<Link key={p.slug} href={`/partners/catalog/${p.slug}`}>
|
||||
<div
|
||||
className="
|
||||
bg-surface-100
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import styles from '~/styles/animations.module.css'
|
||||
import { PlayCircle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Button, cn } from 'ui'
|
||||
import styles from '~/styles/animations.module.css'
|
||||
|
||||
import AnnouncementBadge from '../Announcement/Badge'
|
||||
import ProductIcon from '../ProductIcon'
|
||||
import { PlayCircle } from 'lucide-react'
|
||||
|
||||
interface Types {
|
||||
h1: string | React.ReactNode
|
||||
@@ -23,38 +24,36 @@ interface Types {
|
||||
cta?: {
|
||||
label?: string
|
||||
link: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
secondaryCta?: {
|
||||
label?: string
|
||||
link: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
video?: string
|
||||
}
|
||||
|
||||
const ProductHeaderCentered = (props: Types) => (
|
||||
<div
|
||||
className={cn(
|
||||
'container relative w-full mx-auto px-6 pt-2 pb-0 sm:px-16 xl:px-20',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className={cn('container relative w-full mx-auto px-6 sm:px-16 xl:px-20', props.className)}>
|
||||
<div className="flex flex-col text-center items-center">
|
||||
{props.image && typeof props.image === 'string' ? (
|
||||
<div className="relative w-full max-w-[630px] mx-auto z-0 aspect-[2.3/1] -mt-8 -mb-8 md:-mb-12 lg:-mb-12">
|
||||
<Image
|
||||
src={props.image}
|
||||
priority
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
objectPosition="top"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-12 mt-8 lg:col-span-7 lg:mt-0 xl:col-span-6 xl:col-start-7">
|
||||
{props.image}
|
||||
</div>
|
||||
)}
|
||||
{props.image &&
|
||||
(typeof props.image === 'string' ? (
|
||||
<div className="relative w-full max-w-[630px] mx-auto z-0 aspect-[2.3/1] -mt-8 -mb-8 md:-mb-12 lg:-mb-12">
|
||||
<Image
|
||||
src={props.image}
|
||||
priority
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
objectPosition="top"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-12 mt-8 lg:col-span-7 lg:mt-0 xl:col-span-6 xl:col-start-7">
|
||||
{props.image}
|
||||
</div>
|
||||
))}
|
||||
<div className="relative w-full z-10 flex flex-col items-center space-y-2 mx-auto max-w-2xl">
|
||||
{props.announcement && (
|
||||
<AnnouncementBadge {...props.announcement} className="pb-4 md:pb-4 z-10" />
|
||||
@@ -74,15 +73,15 @@ const ProductHeaderCentered = (props: Types) => (
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn(styles['appear-from-bottom'])}>
|
||||
<h1 className="h1 text-3xl md:text-4xl tracking-[-1.5px]" key={`h1`}>
|
||||
<div className={cn('flex flex-col items-center', styles['appear-from-bottom'])}>
|
||||
<h1 className="h1 text-3xl md:text-4xl! lg:text-5xl! text-balance" key={`h1`}>
|
||||
{props.h1}
|
||||
</h1>
|
||||
<p className="p text-foreground-light!">{props.subheader}</p>
|
||||
<p className="p text-foreground-light text-balance">{props.subheader}</p>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex flex-col items-stretch sm:flex-row pt-2 sm:items-center gap-2">
|
||||
{props.cta && (
|
||||
<Button size="medium" className="text-white" asChild>
|
||||
<Button size="medium" className="text-white" iconRight={props.cta.icon} asChild>
|
||||
<Link href={props.cta.link} as={props.cta.link}>
|
||||
{props.cta.label ?? 'Start for free'}
|
||||
</Link>
|
||||
@@ -96,7 +95,7 @@ const ProductHeaderCentered = (props: Types) => (
|
||||
</Button>
|
||||
)}
|
||||
{props.secondaryCta && (
|
||||
<Button variant="default" size="medium" asChild>
|
||||
<Button variant="default" size="medium" iconRight={props.secondaryCta.icon} asChild>
|
||||
<Link href={props.secondaryCta.link} as={props.secondaryCta.link}>
|
||||
{props.secondaryCta.label}
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Calendar, Pencil, ScrollText } from 'lucide-react'
|
||||
import { Calendar, Pencil, ScrollText, Store } from 'lucide-react'
|
||||
import {
|
||||
IconBriefcase2,
|
||||
IconDocumentation,
|
||||
@@ -19,9 +19,10 @@ export const data = {
|
||||
icon: IconDocumentation,
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
url: '/partners/integrations',
|
||||
icon: IconIntegrations,
|
||||
text: 'Contribute',
|
||||
description: 'How to contribute to Supabase.',
|
||||
url: '/contribute',
|
||||
icon: () => <Pencil className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
text: 'Supabase UI',
|
||||
@@ -75,17 +76,16 @@ export const data = {
|
||||
url: '/open-source',
|
||||
icon: IconGitHubSolid,
|
||||
},
|
||||
{
|
||||
text: 'Contribute',
|
||||
description: 'How to contribute to Supabase.',
|
||||
url: '/contribute',
|
||||
icon: () => <Pencil className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
text: 'Become a Partner',
|
||||
url: '/partners',
|
||||
icon: IconPartners,
|
||||
},
|
||||
{
|
||||
text: 'Partner Catalog',
|
||||
url: '/partners/catalog',
|
||||
icon: IconIntegrations,
|
||||
},
|
||||
{
|
||||
text: 'Careers',
|
||||
url: '/careers',
|
||||
|
||||
@@ -93,8 +93,8 @@ const footerData = [
|
||||
url: '/partners',
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
url: '/partners/integrations',
|
||||
text: 'Partner Catalog',
|
||||
url: '/partners/catalog',
|
||||
},
|
||||
{
|
||||
text: 'Brand Assets',
|
||||
|
||||
@@ -42,6 +42,10 @@ export default {
|
||||
label: 'Become a Partner',
|
||||
link: 'https://forms.supabase.com/partner',
|
||||
},
|
||||
secondaryCta: {
|
||||
label: 'Partner Catalog',
|
||||
link: '/partners/catalog',
|
||||
},
|
||||
},
|
||||
oAuthApp: {
|
||||
steps: [
|
||||
|
||||
@@ -20,6 +20,7 @@ async function generate() {
|
||||
'!pages/api',
|
||||
'!pages/404.tsx',
|
||||
'.next/server/pages/partners/integrations/*.html',
|
||||
'.next/server/pages/partners/catalog/*.html',
|
||||
'.next/server/pages/partners/experts/*.html',
|
||||
'.next/server/pages/features/*.html',
|
||||
])
|
||||
@@ -60,6 +61,7 @@ async function generate() {
|
||||
if (route === '/blog/categories/[category]') return null
|
||||
if (route === '/partners/experts/[slug]') return null
|
||||
if (route === '/partners/integrations/[slug]') return null
|
||||
if (route === '/partners/catalog/[slug]') return null
|
||||
if (route === '/launch-week/ticket-image') return null
|
||||
if (route === '/launch-week/tickets/[username]') return null
|
||||
if (route === '/changelog/[slug]') return null
|
||||
|
||||
+430
-94
@@ -1,66 +1,443 @@
|
||||
import { toPartner as miscDbToPartner, Partner } from '~/types/partners'
|
||||
import { createMarketplaceClient, fullImageUrl, type Listing } from 'common/marketplace-client'
|
||||
import {
|
||||
ListingDetail,
|
||||
toPartner as miscDbToPartner,
|
||||
Partner,
|
||||
type Category,
|
||||
} from '~/types/partners'
|
||||
import {
|
||||
createMarketplaceClient,
|
||||
fullImageUrl,
|
||||
SUPABASE_LISTING_OVERRIDES,
|
||||
SUPABASE_PARTNER_SLUG,
|
||||
type CatalogListing,
|
||||
} from 'common/marketplace-client'
|
||||
|
||||
import supabase from './supabaseMisc'
|
||||
|
||||
// Switch between new Marketplace DB and legacy Supabase Misc DB by updating the environment var
|
||||
// in the Vercel deployment and redeploying, as that will take effect more quickly than flipping a
|
||||
// feature flag in ConfigCat and waiting for the revalidate timeout.
|
||||
const isUseMarketplaceDb =
|
||||
export const isUseMarketplaceDb =
|
||||
process.env.NEXT_PUBLIC_INTEGRATIONS_MARKETPLACE_DB?.toLowerCase() === 'true'
|
||||
|
||||
const marketplaceClient = createMarketplaceClient()
|
||||
|
||||
function toPartner(listing: Listing): Partner {
|
||||
const {
|
||||
categories,
|
||||
featured,
|
||||
slug,
|
||||
title,
|
||||
built_by,
|
||||
description,
|
||||
content,
|
||||
website_url,
|
||||
documentation_url,
|
||||
marketplace_url,
|
||||
listing_logo,
|
||||
images,
|
||||
youtube_id,
|
||||
} = listing
|
||||
return {
|
||||
categories,
|
||||
featured,
|
||||
// The view definition only returns tech partners
|
||||
type: 'technology',
|
||||
slug,
|
||||
title,
|
||||
builtBy: built_by,
|
||||
description,
|
||||
content,
|
||||
websiteUrl: website_url,
|
||||
docsUrl: documentation_url,
|
||||
installUrl: marketplace_url,
|
||||
logo: fullImageUrl(listing_logo),
|
||||
images: images?.map(fullImageUrl) ?? [],
|
||||
youtubeId: youtube_id,
|
||||
}
|
||||
// URL-facing slugs for overridden listings (e.g. 'bigquery', 'firebase', 'stripe').
|
||||
const OVERRIDE_URL_SLUGS = new Set(Object.values(SUPABASE_LISTING_OVERRIDES).map((o) => o.slug))
|
||||
// Reverse map: URL slug → listing DB slug (e.g. 'bigquery' → 'bigquery-wrapper').
|
||||
const URL_SLUG_TO_LISTING: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(SUPABASE_LISTING_OVERRIDES).map(([listingSlug, { slug }]) => [slug, listingSlug])
|
||||
)
|
||||
|
||||
// Only FDW wrappers need the Dashboard Integration treatment (install button + dashboard URL).
|
||||
const MARKETPLACE_SLUGS = ['bigquery-wrapper', 'firebase-wrapper', 'stripe-wrapper']
|
||||
|
||||
/** Returns true if a listing is in the Supabase Marketplace. */
|
||||
export function isMarketplaceListing(listing: CatalogListing): boolean {
|
||||
if (MARKETPLACE_SLUGS.includes(listing.slug)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function getMarketplaceListings(): Promise<Partner[]> {
|
||||
const { data } = await marketplaceClient
|
||||
.from('listings')
|
||||
.select('*')
|
||||
.not('published_in_catalog_at', 'is', null)
|
||||
// Partners whose catalog pages are not yet ready to launch.
|
||||
// Excluded from listing, detail, slug generation, and search.
|
||||
const PRE_LAUNCH_CATALOG_BLOCKLIST = new Set<string>([])
|
||||
|
||||
return data?.map(toPartner) ?? []
|
||||
/** Returns true if a listing is a Foreign Data Wrapper (FDW). */
|
||||
function isFdwListing(listing: CatalogListing): boolean {
|
||||
return !!listing.categories?.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes('foreign data wrapper') || c.slug.toLowerCase().includes('fdw')
|
||||
)
|
||||
}
|
||||
|
||||
/** Deduplicates and merges categories from across all of a partner's listings. */
|
||||
function aggregateCategories(listings: CatalogListing[]): Category[] {
|
||||
const seen = new Map<string, Category>()
|
||||
for (const listing of listings) {
|
||||
for (const cat of listing.categories ?? []) {
|
||||
if (!seen.has(cat.slug)) seen.set(cat.slug, { name: cat.name, slug: cat.slug })
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all partner marketplace entries for the public website, from the configured database.
|
||||
* Picks the listing to use for narrative content (description, docs, images…).
|
||||
* Prefers a Partner Catalog-published listing, then a Dashboard Integration-published one, then any.
|
||||
*/
|
||||
function selectPrimaryListing(listings: CatalogListing[]): CatalogListing | undefined {
|
||||
return (
|
||||
// The catalog_listings view only returns catalog-published listings now (explicitly so, but that was
|
||||
// implicitly the case before) so this doesn't make a ton of sense.
|
||||
//listings.find((l) => !!l.published_in_catalog_at) ??
|
||||
listings.find((l) => l.published_in_marketplace) ?? listings[0]
|
||||
)
|
||||
}
|
||||
|
||||
type MarketplacePartnerRow = {
|
||||
slug: string
|
||||
name: string | null
|
||||
description: string | null
|
||||
logo: string | null
|
||||
website: string | null
|
||||
type: 'technology' | 'expert' | null
|
||||
}
|
||||
|
||||
function buildPartner(row: MarketplacePartnerRow, listings: CatalogListing[]): Partner {
|
||||
const primary = selectPrimaryListing(listings)
|
||||
return {
|
||||
slug: row.slug,
|
||||
title: row.name ?? primary?.partner_name ?? '',
|
||||
builtBy: row.name ?? primary?.built_by ?? '',
|
||||
description: row.description ?? primary?.description ?? '',
|
||||
content: primary?.content ?? '',
|
||||
websiteUrl: row.website ?? primary?.website_url ?? '',
|
||||
docsUrl: primary?.documentation_url || null,
|
||||
installUrl: primary?.marketplace_url ?? null,
|
||||
// Prefer the company logo, fall back to the listing's partner logo, then listing logo
|
||||
logo: fullImageUrl(row.logo ?? primary?.partner_logo ?? primary?.listing_logo ?? ''),
|
||||
images: primary?.images?.map(fullImageUrl) ?? [],
|
||||
youtubeId: primary?.youtube_id ?? null,
|
||||
type: row.type ?? 'technology',
|
||||
categories: aggregateCategories(listings),
|
||||
featured: listings.some((l) => l.featured),
|
||||
// the new view filters these
|
||||
publishedInCatalog: true,
|
||||
// FDW listings count as "Available in Marketplace" even without publish_dashboard.
|
||||
publishedInMarketplace: listings.some(
|
||||
(l) => l.published_in_marketplace || isMarketplaceListing(l)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace DB: list all partners and related listings
|
||||
*/
|
||||
async function getPartnersFromMarketplace(): Promise<Partner[]> {
|
||||
const [{ data: partnersData }, { data: listingsData }] = await Promise.all([
|
||||
marketplaceClient.from('partners').select('*'),
|
||||
marketplaceClient.from('catalog_listings').select('*'),
|
||||
])
|
||||
|
||||
if (!partnersData?.length) return []
|
||||
|
||||
// Check listing.slug first so the override works regardless of what partner_slug the DB has.
|
||||
const overriddenListings = new Map<string, CatalogListing>()
|
||||
const byPartnerSlug = new Map<string, CatalogListing[]>()
|
||||
|
||||
for (const listing of listingsData ?? []) {
|
||||
if (listing.slug in SUPABASE_LISTING_OVERRIDES) {
|
||||
overriddenListings.set(listing.slug, listing)
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = listing.partner_slug
|
||||
if (!slug || slug === SUPABASE_PARTNER_SLUG) continue
|
||||
|
||||
const arr = byPartnerSlug.get(slug) ?? []
|
||||
arr.push(listing)
|
||||
byPartnerSlug.set(slug, arr)
|
||||
}
|
||||
|
||||
// Exclude 'supabase', overridden slugs, pre-launch partners, and partners with no
|
||||
// catalog-published listing.
|
||||
const regularPartners = partnersData
|
||||
.filter(
|
||||
(p) =>
|
||||
p.slug &&
|
||||
p.slug !== SUPABASE_PARTNER_SLUG &&
|
||||
!OVERRIDE_URL_SLUGS.has(p.slug) &&
|
||||
!PRE_LAUNCH_CATALOG_BLOCKLIST.has(p.slug) &&
|
||||
byPartnerSlug.has(p.slug)
|
||||
)
|
||||
.flatMap((p) => {
|
||||
const slug = p.slug
|
||||
if (!slug) return []
|
||||
return [
|
||||
buildPartner(
|
||||
{
|
||||
slug,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
logo: p.logo,
|
||||
website: p.website,
|
||||
type: p.type,
|
||||
},
|
||||
byPartnerSlug.get(slug) ?? []
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const virtualPartners = Array.from(overriddenListings.entries()).map(([listingSlug, listing]) => {
|
||||
const override = SUPABASE_LISTING_OVERRIDES[listingSlug]
|
||||
return buildPartner(
|
||||
{
|
||||
slug: override.slug,
|
||||
name: override.name,
|
||||
description: listing.description,
|
||||
logo: listing.listing_logo,
|
||||
website: listing.website_url,
|
||||
type: 'technology',
|
||||
},
|
||||
[listing]
|
||||
)
|
||||
})
|
||||
|
||||
return [...regularPartners, ...virtualPartners]
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a human-readable tab label from a listing's metadata — always the listing's own
|
||||
* title (e.g. "BigQuery Wrapper", "Stripe Sync Engine"), so partners with more than one listing
|
||||
* get distinct, identifiable tabs instead of generic "Guide"/"Integration" labels.
|
||||
*/
|
||||
function getLabelForListing(listing: CatalogListing): string {
|
||||
return listing.title || listing.slug || 'Listing'
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace DB: get a single partner by partner slug, including all their listings as tabs.
|
||||
*/
|
||||
async function getPartnerFromMarketplace(slug: string): Promise<Partner | null> {
|
||||
// 'supabase' never appears directly as a catalog partner
|
||||
if (slug === SUPABASE_PARTNER_SLUG) return null
|
||||
// Partners blocked from the catalog until they're ready to launch
|
||||
if (PRE_LAUNCH_CATALOG_BLOCKLIST.has(slug)) return null
|
||||
|
||||
// Supabase-owned listings remapped to independent partners.
|
||||
// Accept both the clean URL slug ('bigquery') and the listing DB slug ('bigquery-wrapper').
|
||||
const listingSlugForOverride =
|
||||
URL_SLUG_TO_LISTING[slug] ?? (slug in SUPABASE_LISTING_OVERRIDES ? slug : null)
|
||||
|
||||
if (listingSlugForOverride) {
|
||||
const override = SUPABASE_LISTING_OVERRIDES[listingSlugForOverride]
|
||||
const { data: listing } = await marketplaceClient
|
||||
.from('catalog_listings')
|
||||
.select('*')
|
||||
.eq('slug', listingSlugForOverride)
|
||||
.maybeSingle()
|
||||
|
||||
if (!listing) return null
|
||||
|
||||
return {
|
||||
...buildPartner(
|
||||
{
|
||||
slug: override.slug,
|
||||
name: override.name,
|
||||
description: listing.description,
|
||||
logo: listing.listing_logo,
|
||||
website: listing.website_url,
|
||||
type: 'technology',
|
||||
},
|
||||
[listing]
|
||||
),
|
||||
listings: [
|
||||
{
|
||||
slug: listing.slug,
|
||||
label: getLabelForListing(listing),
|
||||
content: listing.content,
|
||||
publishedInMarketplace: listing.published_in_marketplace || isMarketplaceListing(listing),
|
||||
installUrl: listing.marketplace_url ?? null,
|
||||
dashboardUrl:
|
||||
listing.published_in_marketplace || isMarketplaceListing(listing)
|
||||
? `https://supabase.com/dashboard/project/_/integrations/${isFdwListing(listing) ? listing.slug.replaceAll('-', '_') : listing.slug}/overview`
|
||||
: null,
|
||||
docsUrl: listing.documentation_url || null,
|
||||
images: listing.images?.map(fullImageUrl) ?? [],
|
||||
youtubeId: listing.youtube_id ?? null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const [{ data: partnerData }, { data: listingsData }] = await Promise.all([
|
||||
marketplaceClient.from('partners').select('*').eq('slug', slug).maybeSingle(),
|
||||
marketplaceClient.from('catalog_listings').select('*').eq('partner_slug', slug),
|
||||
])
|
||||
|
||||
const partnerSlug = partnerData?.slug
|
||||
if (!partnerSlug) return null
|
||||
|
||||
const listings = listingsData ?? []
|
||||
// No catalog-published listing → this partner isn't part of the catalog.
|
||||
if (!listings.length) return null
|
||||
|
||||
const listingDetails: ListingDetail[] = listings.map((listing) => ({
|
||||
slug: listing.slug,
|
||||
label: getLabelForListing(listing),
|
||||
content: listing.content,
|
||||
publishedInMarketplace: listing.published_in_marketplace || isMarketplaceListing(listing),
|
||||
installUrl: listing.marketplace_url ?? null,
|
||||
dashboardUrl:
|
||||
listing.published_in_marketplace || isMarketplaceListing(listing)
|
||||
? `https://supabase.com/dashboard/project/_/integrations/${isFdwListing(listing) ? listing.slug.replaceAll('-', '_') : listing.slug}/overview`
|
||||
: null,
|
||||
docsUrl: listing.documentation_url || null,
|
||||
images: listing.images?.map(fullImageUrl) ?? [],
|
||||
youtubeId: listing.youtube_id ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
...buildPartner(
|
||||
{
|
||||
slug: partnerSlug,
|
||||
name: partnerData.name,
|
||||
description: partnerData.description,
|
||||
logo: partnerData.logo,
|
||||
website: partnerData.website,
|
||||
type: partnerData.type,
|
||||
},
|
||||
listings
|
||||
),
|
||||
listings: listingDetails,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace DB: list all partner slugs (for static path generation).
|
||||
* Excludes 'supabase'; adds the remapped listing slugs instead.
|
||||
*/
|
||||
async function getPartnerSlugsFromMarketplace(): Promise<string[]> {
|
||||
const [{ data: catalogListingRows }, { data: overriddenRows }] = await Promise.all([
|
||||
// Derive partner slugs from catalog-published listings, not the raw partners table —
|
||||
// a partner with no catalog-published listing isn't part of the Partner Catalog.
|
||||
marketplaceClient.from('catalog_listings').select('partner_slug'),
|
||||
// Fetch overridden listing slugs directly by listing slug (not by partner_slug).
|
||||
marketplaceClient
|
||||
.from('catalog_listings')
|
||||
.select('slug')
|
||||
.in('slug', Object.keys(SUPABASE_LISTING_OVERRIDES)),
|
||||
])
|
||||
|
||||
// Exclude 'supabase', any slug covered by an override, and pre-launch partners.
|
||||
const catalogPartnerSlugs = new Set(
|
||||
catalogListingRows?.flatMap((row) => (row.partner_slug ? [row.partner_slug] : [])) ?? []
|
||||
)
|
||||
const partnerSlugs = Array.from(catalogPartnerSlugs).filter(
|
||||
(slug) =>
|
||||
slug !== SUPABASE_PARTNER_SLUG &&
|
||||
!OVERRIDE_URL_SLUGS.has(slug) &&
|
||||
!PRE_LAUNCH_CATALOG_BLOCKLIST.has(slug)
|
||||
)
|
||||
// Map each listing DB slug to its clean URL slug (e.g. 'bigquery-wrapper' → 'bigquery').
|
||||
const overriddenSlugs =
|
||||
overriddenRows?.flatMap((row) => {
|
||||
const override = row.slug ? SUPABASE_LISTING_OVERRIDES[row.slug] : null
|
||||
return override ? [override.slug] : []
|
||||
}) ?? []
|
||||
return [...partnerSlugs, ...overriddenSlugs]
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace DB: search (returns one result per partner, not per listing)
|
||||
*/
|
||||
async function searchPartnersFromMarketplace(search: string): Promise<Partner[] | null> {
|
||||
const searchTerm = search.trim()
|
||||
|
||||
if (!searchTerm) return getPartnersFromMarketplace()
|
||||
|
||||
const searchPattern = `%${searchTerm}%`
|
||||
|
||||
const { data: nameMatchedPartners, error: partnersError } = await marketplaceClient
|
||||
.from('partners')
|
||||
.select('slug')
|
||||
.ilike('name', searchPattern)
|
||||
|
||||
if (partnersError) {
|
||||
console.error('Marketplace search error:', partnersError)
|
||||
return null
|
||||
}
|
||||
|
||||
const nameMatchedSlugs = nameMatchedPartners?.flatMap((p) => (p.slug ? [p.slug] : [])) ?? []
|
||||
|
||||
const orConditions = [
|
||||
`title.ilike.${searchPattern}`,
|
||||
`description.ilike.${searchPattern}`,
|
||||
`partner_name.ilike.${searchPattern}`,
|
||||
]
|
||||
if (nameMatchedSlugs.length) {
|
||||
orConditions.push(`partner_slug.in.(${nameMatchedSlugs.join(',')})`)
|
||||
}
|
||||
|
||||
const { data, error } = await marketplaceClient
|
||||
.from('catalog_listings')
|
||||
.select('*')
|
||||
.or(orConditions.join(','))
|
||||
|
||||
if (error) {
|
||||
console.error('Marketplace search error:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
// Use clean URL slug as map key for overridden listings.
|
||||
const byPartnerSlug = new Map<string, CatalogListing[]>()
|
||||
for (const listing of data ?? []) {
|
||||
if (listing.slug in SUPABASE_LISTING_OVERRIDES) {
|
||||
byPartnerSlug.set(SUPABASE_LISTING_OVERRIDES[listing.slug].slug, [listing])
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = listing.partner_slug
|
||||
if (!slug || slug === SUPABASE_PARTNER_SLUG || PRE_LAUNCH_CATALOG_BLOCKLIST.has(slug)) continue
|
||||
|
||||
const arr = byPartnerSlug.get(slug) ?? []
|
||||
arr.push(listing)
|
||||
byPartnerSlug.set(slug, arr)
|
||||
}
|
||||
|
||||
// Fetch partner-level names (which take priority over listing.partner_name, see buildPartner)
|
||||
// for every non-overridden partner surfaced above.
|
||||
const plainPartnerSlugs = Array.from(byPartnerSlug.keys()).filter(
|
||||
(slug) => !URL_SLUG_TO_LISTING[slug]
|
||||
)
|
||||
const { data: partnerRows } = plainPartnerSlugs.length
|
||||
? await marketplaceClient.from('partners').select('slug, name').in('slug', plainPartnerSlugs)
|
||||
: { data: [] }
|
||||
const partnerNameBySlug = new Map((partnerRows ?? []).map((p) => [p.slug, p.name]))
|
||||
|
||||
return Array.from(byPartnerSlug.entries()).map(([partnerSlug, listings]) => {
|
||||
const first = listings[0]
|
||||
const listingSlug = URL_SLUG_TO_LISTING[partnerSlug]
|
||||
const isOverridden = !!listingSlug
|
||||
return buildPartner(
|
||||
{
|
||||
slug: partnerSlug,
|
||||
name: isOverridden
|
||||
? SUPABASE_LISTING_OVERRIDES[listingSlug].name
|
||||
: (partnerNameBySlug.get(partnerSlug) ?? first.partner_name),
|
||||
description: first.description,
|
||||
logo: isOverridden ? first.listing_logo : (first.partner_logo ?? first.listing_logo),
|
||||
website: first.website_url,
|
||||
type: 'technology',
|
||||
},
|
||||
listings
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog API — always uses the marketplace DB directly, independent of isUseMarketplaceDb.
|
||||
// The Partner Catalog is backed by the marketplace DB; the legacy misc DB does not carry
|
||||
// the listings (BigQuery/Firebase/Stripe FDW integrations, etc.) that the catalog needs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listCatalogPartners = getPartnersFromMarketplace
|
||||
export const searchCatalogPartners = searchPartnersFromMarketplace
|
||||
export const getCatalogPartner = getPartnerFromMarketplace
|
||||
export const listCatalogPartnerSlugs = getPartnerSlugsFromMarketplace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — respects isUseMarketplaceDb for non-catalog pages (e.g. /partners/integrations).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lists all partner entries for the public website, from the configured database.
|
||||
* Returns one entry per partner company, with categories and Dashboard Integration flags
|
||||
* aggregated across all of that partner's listings.
|
||||
*/
|
||||
export async function listPartners(): Promise<Partner[]> {
|
||||
if (isUseMarketplaceDb) {
|
||||
return getMarketplaceListings()
|
||||
return getPartnersFromMarketplace()
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('partners')
|
||||
@@ -74,21 +451,13 @@ export async function listPartners(): Promise<Partner[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getMarketplaceListingSlugs(): Promise<string[]> {
|
||||
const { data } = await marketplaceClient
|
||||
.from('listings')
|
||||
.select('slug')
|
||||
.not('published_in_catalog_at', 'is', null)
|
||||
|
||||
return data?.map((row) => row.slug) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available partner marketplace listing slugs, from the configured database.
|
||||
* Lists all available partner slugs, from the configured database.
|
||||
* Used for static path generation on the partner detail pages.
|
||||
*/
|
||||
export async function listPartnerSlugs(): Promise<string[]> {
|
||||
if (isUseMarketplaceDb) {
|
||||
return getMarketplaceListingSlugs()
|
||||
return getPartnerSlugsFromMarketplace()
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('partners')
|
||||
@@ -100,36 +469,13 @@ export async function listPartnerSlugs(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMarketplaceListings(search: string): Promise<Partner[] | null> {
|
||||
const searchTerm = search.trim()
|
||||
let query = marketplaceClient
|
||||
.from('listings')
|
||||
.select('*')
|
||||
.not('published_in_catalog_at', 'is', null)
|
||||
|
||||
if (searchTerm) {
|
||||
const searchPattern = `%${searchTerm}%`
|
||||
query = query.or(
|
||||
`title.ilike.${searchPattern},description.ilike.${searchPattern},partner_name.ilike.${searchPattern}`
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Marketplace search error:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data?.map(toPartner) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for partner marketplace listings, from the configured database.
|
||||
* Searches for partners, from the configured database.
|
||||
* Returns one result per partner company (not per listing).
|
||||
*/
|
||||
export async function searchPartners(search: string): Promise<Partner[] | null> {
|
||||
if (isUseMarketplaceDb) {
|
||||
return searchMarketplaceListings(search)
|
||||
return searchPartnersFromMarketplace(search)
|
||||
} else {
|
||||
const searchTerm = search.trim()
|
||||
let query = supabase
|
||||
@@ -155,25 +501,15 @@ 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)
|
||||
.not('published_in_catalog_at', 'is', null)
|
||||
.single()
|
||||
|
||||
return data ? toPartner(data) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single partner by slug, from the configured database.
|
||||
* For the marketplace DB, slug is the partner slug (not a listing slug).
|
||||
*/
|
||||
export async function getPartner(slug: string): Promise<Partner | null> {
|
||||
if (isUseMarketplaceDb) {
|
||||
return getMarketplaceListing(slug)
|
||||
return getPartnerFromMarketplace(slug)
|
||||
} else {
|
||||
let { data } = await supabase
|
||||
const { data } = await supabase
|
||||
.from('partners')
|
||||
.select('*')
|
||||
.eq('type', 'technology')
|
||||
|
||||
+76
-42
@@ -2328,85 +2328,112 @@ module.exports = [
|
||||
source: '/blog/flutter-authentication-and-authorization-with-rls',
|
||||
destination: '/blog/flutter-authorization-with-rls',
|
||||
},
|
||||
// /partners/integrations → /partners/catalog redirects
|
||||
// Wrapper listing slugs → clean partner slugs (open the matching listing tab)
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/catalog/bigquery-wrapper',
|
||||
destination: '/partners/catalog/bigquery?tab=bigquery-wrapper',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/catalog/firebase-wrapper',
|
||||
destination: '/partners/catalog/firebase?tab=firebase-wrapper',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/catalog/stripe-wrapper',
|
||||
destination: '/partners/catalog/stripe?tab=stripe-wrapper',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/catalog/aikido-security',
|
||||
destination: '/partners/catalog/aikido',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations',
|
||||
destination: '/partners/catalog',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/appsmith',
|
||||
destination: '/partners/integrations/appsmith',
|
||||
destination: '/partners/catalog/appsmith',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/auth0',
|
||||
destination: '/partners/integrations/auth0',
|
||||
destination: '/partners/catalog/auth0',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/authsignal',
|
||||
destination: '/partners/integrations/authsignal',
|
||||
destination: '/partners/catalog/authsignal',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/bracket',
|
||||
destination: '/partners/integrations/bracket',
|
||||
destination: '/partners/catalog/bracket',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/clerk',
|
||||
destination: '/partners/integrations/clerk',
|
||||
destination: '/partners/catalog/clerk',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/cloudflare-workers',
|
||||
destination: '/partners/integrations/cloudflare-workers',
|
||||
destination: '/partners/catalog/cloudflare-workers',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/dhiwise',
|
||||
destination: '/partners/integrations/dhiwise',
|
||||
destination: '/partners/catalog/dhiwise',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/directus',
|
||||
destination: '/partners/integrations/directus',
|
||||
destination: '/partners/catalog/directus',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/draftbit',
|
||||
destination: '/partners/integrations/draftbit',
|
||||
destination: '/partners/catalog/draftbit',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/estuary',
|
||||
destination: '/partners/integrations/estuary',
|
||||
destination: '/partners/catalog/estuary',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/fezto',
|
||||
destination: '/partners/integrations/fezto',
|
||||
destination: '/partners/catalog/fezto',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/flutterflow',
|
||||
destination: '/partners/integrations/flutterflow',
|
||||
destination: '/partners/catalog/flutterflow',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/forestadmin',
|
||||
destination: '/partners/integrations/forestadmin',
|
||||
destination: '/partners/catalog/forestadmin',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/illa',
|
||||
destination: '/partners/integrations/illa',
|
||||
destination: '/partners/catalog/illa',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/integrations',
|
||||
destination: '/partners/integrations',
|
||||
destination: '/partners/catalog',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/keyri',
|
||||
destination: '/partners/integrations/keyri',
|
||||
destination: '/partners/catalog/keyri',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
@@ -2421,108 +2448,110 @@ module.exports = [
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/onesignal',
|
||||
destination: '/partners/integrations/onesignal',
|
||||
destination: '/partners/catalog/onesignal',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/passage',
|
||||
destination: '/partners/integrations/passage',
|
||||
destination: '/partners/catalog/passage',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/pgmustard',
|
||||
destination: '/partners/integrations/pgmustard',
|
||||
destination: '/partners/catalog/pgmustard',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/picket',
|
||||
destination: '/partners/integrations/picket',
|
||||
destination: '/partners/catalog/picket',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/plasmic',
|
||||
destination: '/partners/integrations/plasmic',
|
||||
destination: '/partners/catalog/plasmic',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/polyscale',
|
||||
destination: '/partners/integrations/polyscale',
|
||||
destination: '/partners/catalog/polyscale',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/prisma',
|
||||
destination: '/partners/integrations/prisma',
|
||||
destination: '/partners/catalog/prisma',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/atomic_crm',
|
||||
destination: '/partners/integrations/atomic-crm',
|
||||
destination: '/partners/catalog/atomic-crm',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/refine_dev',
|
||||
destination: '/partners/integrations/refine',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/sequin_io',
|
||||
destination: '/partners/integrations/sequin',
|
||||
destination: '/partners/catalog/refine',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/supabase_wrapper_bigquery',
|
||||
destination: '/partners/integrations/bigquery-wrapper',
|
||||
destination: '/partners/catalog/bigquery?tab=bigquery-wrapper',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/supabase_wrapper_firebase',
|
||||
destination: '/partners/integrations/firebase-wrapper',
|
||||
destination: '/partners/catalog/firebase?tab=firebase-wrapper',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/supabase_wrapper_stripe',
|
||||
destination: '/partners/integrations/stripe-wrapper',
|
||||
destination: '/partners/catalog/stripe?tab=stripe-wrapper',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/sequin',
|
||||
destination: '/partners/integrations/sequin',
|
||||
destination: '/partners/catalog/sequin',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/snaplet',
|
||||
destination: '/partners/integrations/snaplet',
|
||||
destination: '/partners/catalog/snaplet',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/stytch',
|
||||
destination: '/partners/integrations/stytch',
|
||||
destination: '/partners/catalog/stytch',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/supertokens',
|
||||
destination: '/partners/integrations/supertokens',
|
||||
destination: '/partners/catalog/supertokens',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/vercel',
|
||||
destination: '/partners/integrations/vercel',
|
||||
destination: '/partners/catalog/vercel',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/weweb',
|
||||
destination: '/partners/integrations/weweb',
|
||||
destination: '/partners/catalog/weweb',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/zuplo',
|
||||
destination: '/partners/integrations/zuplo',
|
||||
destination: '/partners/catalog/zuplo',
|
||||
},
|
||||
// toddle.dev pivoted to nordcraft
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/toddle',
|
||||
destination: '/partners/integrations/nordcraft',
|
||||
destination: '/partners/catalog/nordcraft',
|
||||
},
|
||||
// Catch-all: any other /partners/integrations/<slug> not covered by a specific rule above.
|
||||
// Must come after all specific /partners/integrations/* rules
|
||||
{
|
||||
permanent: true,
|
||||
source: '/partners/integrations/:slug*',
|
||||
destination: '/partners/catalog/:slug*',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
@@ -2948,7 +2977,12 @@ module.exports = [
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/platform/marketplace',
|
||||
destination: '/docs/guides/integrations/supabase-marketplace',
|
||||
destination: '/docs/guides/integrations/partner-catalog',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/integrations/supabase-marketplace',
|
||||
destination: '/docs/guides/integrations/partner-catalog',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { GetStaticPaths, GetStaticProps } from 'next'
|
||||
import supabase from '~/lib/supabaseMisc'
|
||||
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/integrations/${partner.slug}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default PartnerPage
|
||||
@@ -36,7 +36,7 @@ const Partners = () => {
|
||||
/>
|
||||
<DefaultLayout>
|
||||
<div className="relative bg-alternative overflow-hidden">
|
||||
<SectionContainer className="overflow-hidden pt-8 pb-12 md:pt-12">
|
||||
<SectionContainer className="overflow-hidden pt-8 pb-12">
|
||||
<ProductHeaderCentered {...pageData.heroSection} />
|
||||
</SectionContainer>
|
||||
<div className="relative z-20 w-full flex py-16 mb-16 -mt-10 md:mb-24 md:-mt-20 justify-center gap-2 overflow-hidden mx-auto max-w-4xl before:content[''] before:absolute before:inset-0 before:w-full before:bg-[linear-gradient(to_right,var(--background-alternative-default)_0%,transparent_10%,transparent_90%,var(--background-alternative-default)_100%)] before:z-10">
|
||||
@@ -139,7 +139,7 @@ const Partners = () => {
|
||||
Use your favorite tools with Supabase.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/partners/integrations" className="absolute inset-0" />
|
||||
<Link href="/partners/catalog" className="absolute inset-0" />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
|
||||
import { CH } from '@code-hike/mdx/components'
|
||||
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 ImageModal from '~/components/ImageModal'
|
||||
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 { 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 { 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 = {
|
||||
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())} />
|
||||
},
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
type PartnerData = {
|
||||
partner: Partner
|
||||
overview: MDXRemoteSerializeResult<Record<string, unknown>, Record<string, unknown>>
|
||||
}
|
||||
|
||||
function Partner({ partner, overview }: PartnerData) {
|
||||
const [focusedImage, setFocusedImage] = useState<string | null>(null)
|
||||
const isNarrow = useBreakpoint('lg')
|
||||
|
||||
if (!partner) return <Error404 />
|
||||
|
||||
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/integrations/${partner.slug}`,
|
||||
images: [
|
||||
{
|
||||
url: partner.images ? partner.images[0] : partner.logo,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{focusedImage ? (
|
||||
<ImageModal
|
||||
visible
|
||||
onCancel={() => setFocusedImage(null)}
|
||||
size="xxlarge"
|
||||
className="w-full outline-hidden"
|
||||
>
|
||||
<Image
|
||||
layout="responsive"
|
||||
objectFit="contain"
|
||||
width={1152}
|
||||
height={766}
|
||||
src={focusedImage!}
|
||||
alt={partner.title}
|
||||
/>
|
||||
</ImageModal>
|
||||
) : null}
|
||||
<DefaultLayout>
|
||||
<SectionContainer>
|
||||
<div className="col-span-12 mx-auto mb-2 space-y-10 lg:col-span-2">
|
||||
{/* Back button */}
|
||||
<Link
|
||||
href="/partners/integrations"
|
||||
className="text-foreground hover:text-foreground-lighter flex cursor-pointer items-center transition-colors"
|
||||
>
|
||||
<ChevronLeft width={14} height={14} />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
<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"
|
||||
src={partner.logo}
|
||||
alt={partner.title}
|
||||
/>
|
||||
<h1 className="h1" style={{ marginBottom: 0 }}>
|
||||
{partner.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-linear-to-t from-background-alternative to-background border-b p-6 [&_.swiper-container]: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">
|
||||
<Swiper
|
||||
initialSlide={0}
|
||||
spaceBetween={20}
|
||||
slidesPerView={4}
|
||||
speed={300}
|
||||
grabCursor
|
||||
centeredSlides={false}
|
||||
centerInsufficientSlides={false}
|
||||
breakpoints={{
|
||||
320: {
|
||||
slidesPerView: 1.25,
|
||||
centeredSlides: false,
|
||||
spaceBetween: 10,
|
||||
},
|
||||
720: {
|
||||
slidesPerView: 2,
|
||||
centeredSlides: false,
|
||||
spaceBetween: 10,
|
||||
},
|
||||
920: {
|
||||
slidesPerView: 3,
|
||||
centeredSlides: false,
|
||||
},
|
||||
1024: {
|
||||
slidesPerView: 4,
|
||||
},
|
||||
1280: {
|
||||
slidesPerView: 5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{partner.images?.map((image: any, i: number) => {
|
||||
return (
|
||||
<SwiperSlide key={i}>
|
||||
<div className="relative block overflow-hidden rounded-md">
|
||||
<Image
|
||||
layout="responsive"
|
||||
objectFit="contain"
|
||||
placeholder="blur"
|
||||
blurDataURL="/images/blur.png"
|
||||
width={1460}
|
||||
height={960}
|
||||
src={image}
|
||||
alt={partner.title}
|
||||
onClick={() => setFocusedImage(image)}
|
||||
/>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
)
|
||||
})}
|
||||
</Swiper>
|
||||
</SectionContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-y-12 lg:grid-cols-8 lg:space-x-12">
|
||||
{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: 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>
|
||||
</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 font-medium self-center">
|
||||
Get started with {partner.title} and Supabase.
|
||||
</h1>
|
||||
<a href={partner.installUrl} target="_blank" rel="noreferrer">
|
||||
<Button size="medium" variant="secondary">
|
||||
Add integration
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PartnerDetails = ({ partner }: { partner: Partner }) => {
|
||||
const videoThumbnail = partner.youtubeId
|
||||
? `https://img.youtube.com/vi/${partner.youtubeId}/0.jpg`
|
||||
: undefined
|
||||
|
||||
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>
|
||||
|
||||
{partner.youtubeId && (
|
||||
<ExpandableVideo
|
||||
videoId={partner.youtubeId}
|
||||
// imgUrl={videoThumbnail}
|
||||
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.builtBy}</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/integrations#${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">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
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
const codeHikeOptions: CodeHikeConfig = {
|
||||
theme: codeHikeTheme,
|
||||
lineNumbers: true,
|
||||
showCopyButton: true,
|
||||
skipLanguages: [],
|
||||
autoImport: false,
|
||||
}
|
||||
|
||||
// Parse markdown
|
||||
const overview = await serialize({
|
||||
source: partner.content,
|
||||
options: {
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
props: { partner, overview },
|
||||
revalidate: 1800, // 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
export default Partner
|
||||
@@ -1,193 +0,0 @@
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import BecomeAPartner from '~/components/Partners/BecomeAPartner'
|
||||
import PartnerLinkBox from '~/components/Partners/PartnerLinkBox'
|
||||
import { listPartners, searchPartners } from '~/lib/marketplaceDb'
|
||||
import { type Category, type Partner } from '~/types/partners'
|
||||
import { Loader, Search } from 'lucide-react'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
import TileGrid from '../../../components/Partners/TileGrid'
|
||||
|
||||
export async function getStaticProps(): Promise<{ props: Props; revalidate: number }> {
|
||||
return {
|
||||
props: {
|
||||
partners: await listPartners(),
|
||||
},
|
||||
// TODO: consider using Next.js' On-demand Revalidation with Supabase Database Webhooks instead
|
||||
revalidate: 1800, // 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
partners: Partner[]
|
||||
}
|
||||
|
||||
function IntegrationPartnersPage(props: Props) {
|
||||
const initialPartners = props.partners
|
||||
const [partners, setPartners] = useState(initialPartners)
|
||||
|
||||
const categoryMap: { [slug: string]: Category } = {}
|
||||
initialPartners.forEach((p) =>
|
||||
p.categories.forEach((c) => {
|
||||
if (!(c.slug in categoryMap)) {
|
||||
categoryMap[c.slug] = c
|
||||
}
|
||||
})
|
||||
)
|
||||
const allCategories = Object.keys(categoryMap)
|
||||
.toSorted()
|
||||
.map((slug) => categoryMap[slug])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const meta_title = 'Find an Integration'
|
||||
const meta_description = `Use your favorite tools with Supabase.`
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearchTerm] = useDebounce(search, 300)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const searchIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm.trim() === '') {
|
||||
setIsSearching(false)
|
||||
setPartners(initialPartners)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
const currentSearchId = ++searchIdRef.current
|
||||
|
||||
searchPartners(debouncedSearchTerm)
|
||||
.then((results) => {
|
||||
if (currentSearchId === searchIdRef.current) {
|
||||
setPartners(results ?? [])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (currentSearchId === searchIdRef.current) {
|
||||
setPartners([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (currentSearchId === searchIdRef.current) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
})
|
||||
}, [debouncedSearchTerm, initialPartners])
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={meta_title}
|
||||
description={meta_description}
|
||||
openGraph={{
|
||||
title: meta_title,
|
||||
description: meta_description,
|
||||
url: `https://supabase.com/partners/integrations`,
|
||||
images: [
|
||||
{
|
||||
url: `https://supabase.com${router.basePath}/images/og/integrations.png`, // TODO
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<DefaultLayout className="bg-alternative">
|
||||
<SectionContainer className="space-y-16">
|
||||
<div>
|
||||
<h1 className="h1">{meta_title}</h1>
|
||||
<p className="text-foreground-lighter text-xl">{meta_description}</p>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className="grid space-y-12 md:gap-8 lg:grid-cols-12 lg:gap-16 lg:space-y-0 xl:gap-16">
|
||||
<div className="lg:col-span-4 xl:col-span-3">
|
||||
{/* Horizontal link menu */}
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupInput
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
{isSearching && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="mr-1 animate-spin text-white">
|
||||
<Loader />
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<div className="hidden lg:block">
|
||||
<div className="text-foreground-lighter mb-2 text-sm">Categories</div>
|
||||
<div className="space-y-1">
|
||||
{allCategories.map((category) => (
|
||||
<button
|
||||
key={category.slug}
|
||||
onClick={() => router.push(`#${category.slug}`)}
|
||||
className="text-foreground-light block text-base cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="text-foreground-lighter mb-2 text-sm">Explore more</div>
|
||||
<div className="grid grid-cols-2 gap-8 lg:grid-cols-1">
|
||||
<PartnerLinkBox
|
||||
href={`/partners/integrations#become-a-partner`}
|
||||
title="Become a partner"
|
||||
color="brand"
|
||||
description="Fill out a quick 30 second form to apply to become a partner"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-8 xl:col-span-9">
|
||||
{/* Partner Tiles */}
|
||||
<div className="grid space-y-10">
|
||||
{partners?.length ? (
|
||||
<TileGrid partners={partners} />
|
||||
) : (
|
||||
<p className="h2">No Partners Found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
<BecomeAPartner />
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntegrationPartnersPage
|
||||
@@ -4,16 +4,16 @@
|
||||
{
|
||||
"name": "supabase",
|
||||
"type": "archive",
|
||||
"description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).",
|
||||
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase.tar.gz",
|
||||
"digest": "sha256:5acb0974e54db43ac66fcb55170dee2059b423d5253a9c1f14a51df44358f07a"
|
||||
"description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, declarative schemas, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).",
|
||||
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.6/supabase.tar.gz",
|
||||
"digest": "sha256:35dac7224b4c9594d6db63f197120ea4168581196edd741164c925aaeab6ec83"
|
||||
},
|
||||
{
|
||||
"name": "supabase-postgres-best-practices",
|
||||
"type": "archive",
|
||||
"description": "Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.",
|
||||
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase-postgres-best-practices.tar.gz",
|
||||
"digest": "sha256:2db40cf84865e73c908ff080d101ab1ca60829cd379f4e6c15d7287d33ce61e5"
|
||||
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.6/supabase-postgres-best-practices.tar.gz",
|
||||
"digest": "sha256:57a5315af73864add7bc42eaa3d5c9f309c767d50185583165e6ecd1abd30404"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,9 +7,23 @@ export type Category = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type ListingDetail = {
|
||||
slug: string
|
||||
label: string
|
||||
content: string
|
||||
publishedInMarketplace: boolean
|
||||
installUrl: string | null
|
||||
dashboardUrl?: string | null
|
||||
docsUrl: string | null
|
||||
images: string[]
|
||||
youtubeId: string | null
|
||||
}
|
||||
|
||||
export type Partner = {
|
||||
categories: Category[]
|
||||
featured: boolean
|
||||
publishedInCatalog: boolean
|
||||
publishedInMarketplace?: boolean
|
||||
type: 'technology' | 'expert'
|
||||
slug: string
|
||||
title: string
|
||||
@@ -22,6 +36,7 @@ export type Partner = {
|
||||
logo: string
|
||||
images: string[]
|
||||
youtubeId: string | null
|
||||
listings?: ListingDetail[]
|
||||
}
|
||||
|
||||
export function toPartner(dbPartner: DbPartner): Partner {
|
||||
@@ -44,6 +59,8 @@ export function toPartner(dbPartner: DbPartner): Partner {
|
||||
return {
|
||||
categories: [{ name: category, slug: category.toLowerCase() }],
|
||||
featured: featured ?? false,
|
||||
publishedInCatalog: false, // has at least one listing shown on the partner catalog page
|
||||
publishedInMarketplace: false, // has at least one one-click-installable listing in the dashboard
|
||||
type,
|
||||
slug,
|
||||
title,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Cable,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
Package2,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Webhook,
|
||||
Wrench,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Maps integration category slugs to Lucide icons.
|
||||
* Used in both Supabase Studio's Dashboard Integrations and the public Partner Catalog.
|
||||
* Unknown slugs fall back to the neutral Boxes icon.
|
||||
*/
|
||||
export const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
||||
observability: BarChart3,
|
||||
security: ShieldCheck,
|
||||
billing: CreditCard,
|
||||
secrets: KeyRound,
|
||||
email: Mail,
|
||||
wrappers: Database,
|
||||
ai: Cpu,
|
||||
ai_vectors: Cpu,
|
||||
storage: Package2,
|
||||
postgres_extension: Database,
|
||||
wrapper: Cable,
|
||||
devtools: Wrench,
|
||||
auth: Fingerprint,
|
||||
'low-code': MousePointerClick,
|
||||
'data-platform': Server,
|
||||
api: Webhook,
|
||||
'caching-offline-first': Zap,
|
||||
messaging: MessageSquare,
|
||||
'foreign-data-wrapper': Cable,
|
||||
fdw: Cable,
|
||||
}
|
||||
|
||||
export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => {
|
||||
if (!slug) return Boxes
|
||||
return CATEGORY_ICONS[slug] ?? Boxes
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export type Database = MergeDeep<
|
||||
{
|
||||
public: {
|
||||
Views: {
|
||||
listings: {
|
||||
catalog_listings: {
|
||||
Row: {
|
||||
// add a type for the JSON structure
|
||||
categories: Category[]
|
||||
@@ -31,6 +31,7 @@ export type Database = MergeDeep<
|
||||
website_url: string
|
||||
documentation_url: string
|
||||
listing_logo: string
|
||||
published_in_marketplace: boolean
|
||||
}
|
||||
}
|
||||
marketplace_listings: {
|
||||
@@ -56,8 +57,25 @@ export type Database = MergeDeep<
|
||||
}
|
||||
>
|
||||
|
||||
export type Listing = Database['public']['Views']['listings']['Row']
|
||||
export type CatalogListing = Database['public']['Views']['catalog_listings']['Row']
|
||||
export type MarketplaceListing = Database['public']['Views']['marketplace_listings']['Row']
|
||||
export type Partner = Database['public']['Views']['partners']['Row']
|
||||
|
||||
// 'supabase' never gets its own catalog partner page — getPartnerFromMarketplace in
|
||||
// apps/www/lib/marketplaceDb.ts always 404s on this slug.
|
||||
export const SUPABASE_PARTNER_SLUG = 'supabase'
|
||||
|
||||
// Supabase-owned listings that are remapped to appear as independent partners in the Partner
|
||||
// Catalog, under a clean, partner-like URL slug (e.g. listing 'bigquery-wrapper' renders at
|
||||
// /partners/catalog/bigquery). Key = listing DB slug; value = { display name, clean URL slug
|
||||
// for the catalog page }. Shared so apps/www and apps/docs never drift out of sync.
|
||||
export const SUPABASE_LISTING_OVERRIDES: Record<string, { name: string; slug: string }> = {
|
||||
'bigquery-wrapper': { name: 'BigQuery', slug: 'bigquery' },
|
||||
'firebase-wrapper': { name: 'Firebase', slug: 'firebase' },
|
||||
'stripe-wrapper': { name: 'Stripe', slug: 'stripe' },
|
||||
vercel: { name: 'Vercel', slug: 'vercel' },
|
||||
cyberduck: { name: 'Cyberduck', slug: 'cyberduck' },
|
||||
}
|
||||
|
||||
export const createMarketplaceClient = () => {
|
||||
const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || ''
|
||||
|
||||
@@ -6,6 +6,39 @@ export type Database = {
|
||||
[_ in never]: never
|
||||
}
|
||||
Views: {
|
||||
catalog_listings: {
|
||||
Row: {
|
||||
built_by: string | null
|
||||
categories: Json | null
|
||||
content: string | null
|
||||
description: string | null
|
||||
documentation_url: string | null
|
||||
featured: boolean | null
|
||||
id: string | null
|
||||
images: string[] | null
|
||||
listing_logo: string | null
|
||||
listing_tsv: unknown
|
||||
marketplace_url: string | null
|
||||
partner_id: string | null
|
||||
partner_logo: string | null
|
||||
partner_name: string | null
|
||||
partner_slug: string | null
|
||||
published_in_marketplace: boolean | null
|
||||
slug: string | null
|
||||
title: string | null
|
||||
website_url: string | null
|
||||
youtube_id: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'listings_partner_id_fkey'
|
||||
columns: ['partner_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'partners'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
categories: {
|
||||
Row: {
|
||||
description: string | null
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"valtio": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lucide-react": "*",
|
||||
"@types/lodash": "4.17.5",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
@@ -41,6 +42,7 @@
|
||||
"peerDependencies": {
|
||||
"@supabase/auth-js": "catalog:",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"lucide-react": "*",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
|
||||
@@ -246,7 +246,6 @@ export default function MarketingForm({
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Strip values for fields that are currently hidden so stale data doesn't leak.
|
||||
const submittedValues = Object.fromEntries(
|
||||
Object.entries(values).filter(([name]) => visibleFieldNames.has(name))
|
||||
|
||||
@@ -175,6 +175,17 @@ export const checkboxFieldSchema = formFieldBase.extend({
|
||||
groupRequired: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* Multi-select rendered as a list of checkboxes. Values are stored as a
|
||||
* semicolon-separated string of the selected option `value`s — the format
|
||||
* HubSpot expects for multi-select properties. Notion's multi_select type
|
||||
* splits on the same delimiter when received.
|
||||
*/
|
||||
export const checkboxGroupFieldSchema = formFieldBase.extend({
|
||||
type: z.literal('checkbox-group'),
|
||||
options: z.array(z.object({ label: z.string(), value: z.string() })).min(1),
|
||||
})
|
||||
|
||||
export const formFieldSchema = z.discriminatedUnion('type', [
|
||||
textFieldSchema,
|
||||
urlFieldSchema,
|
||||
|
||||
Generated
+3
@@ -2194,6 +2194,9 @@ importers:
|
||||
'@vitest/ui':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.4(vitest@4.1.4)
|
||||
lucide-react:
|
||||
specifier: '*'
|
||||
version: 0.436.0(react@19.2.6)
|
||||
tsconfig:
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
|
||||
@@ -118,6 +118,7 @@ may_uppercase = [
|
||||
"IdP",
|
||||
"Inbucket",
|
||||
"Index Advisor",
|
||||
"Integrations",
|
||||
"IntelliJ",
|
||||
"Ionic Angular",
|
||||
"Ionic React",
|
||||
@@ -172,6 +173,7 @@ may_uppercase = [
|
||||
"PGAudit",
|
||||
"pgvector",
|
||||
"Pandas",
|
||||
"Partner Catalog",
|
||||
"PgBouncer",
|
||||
"Phoenix",
|
||||
"Pro Plan",
|
||||
|
||||
Reference in new issue
Block a user