From 51a167d91055d562660d141e5be2e466fd108e6b Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 25 Sep 2026 15:53:32 +0200 Subject: [PATCH] feat(www): partners landing page (#47874) ## Summary by CodeRabbit - **New Features** - Added a redesigned Partners page with partnership options, benefits, application steps, FAQs, integration resources, and featured partners. - Added an on-page partner application form with questions tailored to the selected partnership type. - Added a confirmation message with next steps and a link to the OAuth integration guide. - **Bug Fixes** - Updated partner application links to open the form on the Partners page. - Added support for checkbox-group fields in forms. --------- Co-authored-by: Alan Daniel Co-authored-by: Alex Hall Co-authored-by: Dion Zeneli <101271736+Dionysos288@users.noreply.github.com> --- .../workflows/www-partner-form-sync-check.yml | 60 +++ .github/workflows/www-partner-form-sync.yml | 78 ++++ .prettierignore | 2 + apps/www/app/partners/PartnersContent.tsx | 315 ++++++++++++++ .../partners/catalog/IntegrationsContent.tsx | 4 +- apps/www/app/partners/page.tsx | 41 ++ .../components/Partners/BackgroundPattern.tsx | 32 ++ .../components/Partners/BecomeAPartner.tsx | 32 ++ .../Partners/PartnerIntakeForm.fields.ts | 291 +++++++++++++ .../Partners/PartnerIntakeForm.sync.test.ts | 169 ++++++++ .../components/Partners/PartnerIntakeForm.tsx | 33 ++ .../partner-intake-form.hubspot.json | 393 ++++++++++++++++++ apps/www/data/partners/index.tsx | 346 +++++++++------ apps/www/lib/registerFormCrm.ts | 4 + apps/www/lib/staticFormCrm.ts | 41 ++ apps/www/package.json | 2 + apps/www/pages/partners/index.tsx | 222 ---------- apps/www/scripts/syncPartnerIntakeForm.mjs | 109 +++++ apps/www/turbo.jsonc | 2 + apps/www/vitest.config.ts | 13 +- apps/www/vitest.sync.config.ts | 15 + packages/marketing/package.json | 1 + packages/marketing/src/crm/hubspot.ts | 22 +- .../marketing/src/forms/MarketingForm.tsx | 154 +++++-- packages/marketing/src/forms/index.ts | 7 +- .../marketing/src/go/actions/submitForm.ts | 115 +++-- packages/marketing/src/go/schemas.ts | 22 + packages/marketing/src/go/showWhen.ts | 22 + 28 files changed, 2133 insertions(+), 414 deletions(-) create mode 100644 .github/workflows/www-partner-form-sync-check.yml create mode 100644 .github/workflows/www-partner-form-sync.yml create mode 100644 apps/www/app/partners/PartnersContent.tsx create mode 100644 apps/www/app/partners/page.tsx create mode 100644 apps/www/components/Partners/BackgroundPattern.tsx create mode 100644 apps/www/components/Partners/BecomeAPartner.tsx create mode 100644 apps/www/components/Partners/PartnerIntakeForm.fields.ts create mode 100644 apps/www/components/Partners/PartnerIntakeForm.sync.test.ts create mode 100644 apps/www/components/Partners/PartnerIntakeForm.tsx create mode 100644 apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json create mode 100644 apps/www/lib/staticFormCrm.ts delete mode 100644 apps/www/pages/partners/index.tsx create mode 100644 apps/www/scripts/syncPartnerIntakeForm.mjs create mode 100644 apps/www/vitest.sync.config.ts create mode 100644 packages/marketing/src/go/showWhen.ts diff --git a/.github/workflows/www-partner-form-sync-check.yml b/.github/workflows/www-partner-form-sync-check.yml new file mode 100644 index 00000000000..4af0843b4c1 --- /dev/null +++ b/.github/workflows/www-partner-form-sync-check.yml @@ -0,0 +1,60 @@ +name: Partner intake form / HubSpot sync check + +# Deliberately its own workflow, path-filtered to only the files this check +# actually concerns — NOT part of www-tests.yml's `pnpm test` run, which +# gates every apps/www PR. A HubSpot-side change surfaced by the scheduled +# www-partner-form-sync.yml bot PR (which touches only the generated +# snapshot) should fail *this* check, not block unrelated apps/www work. +on: + pull_request: + branches: ['master'] + paths: + - 'apps/www/components/Partners/PartnerIntakeForm.fields.ts' + - 'apps/www/components/Partners/PartnerIntakeForm.sync.test.ts' + - 'apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json' + - 'apps/www/lib/staticFormCrm.ts' + - 'apps/www/vitest.sync.config.ts' + - 'apps/www/package.json' + - '.github/workflows/www-partner-form-sync-check.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: true + +jobs: + check: + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + apps/www + packages + supabase + patches + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Run partner intake form / HubSpot sync check + run: pnpm run test:partner-form-sync + working-directory: ./apps/www diff --git a/.github/workflows/www-partner-form-sync.yml b/.github/workflows/www-partner-form-sync.yml new file mode 100644 index 00000000000..df3c793201b --- /dev/null +++ b/.github/workflows/www-partner-form-sync.yml @@ -0,0 +1,78 @@ +name: Sync partner intake form from HubSpot + +on: + schedule: + # Run at 00:00 UTC every Monday + - cron: '0 0 * * 1' + workflow_dispatch: + +permissions: + pull-requests: write + contents: write + +jobs: + sync-partner-form: + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.ref }} + sparse-checkout: | + apps/www + packages + patches + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Fetch the live HubSpot form and regenerate the snapshot + run: pnpm run sync:partner-form + working-directory: ./apps/www + + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }} + permission-pull-requests: write + permission-contents: write + + # Only opens/updates a PR when the regenerated snapshot actually + # differs from what's committed — i.e. only when the live HubSpot form + # has changed since the last sync. The PR's existence is the drift + # alert: PartnerIntakeForm.sync.test.ts (run by the normal + # www-tests.yml suite on this PR) fails until a human reconciles + # PartnerIntakeForm.tsx / staticFormCrm.ts with the new snapshot. + - name: Create pull request + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: 'chore(www): sync partner intake form snapshot from HubSpot' + title: 'chore(www): partner intake form has changed in HubSpot' + body: | + The live "Become a Partner" HubSpot form no longer matches the + checked-in snapshot at + `apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json`. + + `PartnerIntakeForm.sync.test.ts` will fail on this PR until + `apps/www/components/Partners/PartnerIntakeForm.fields.ts` and + `apps/www/lib/staticFormCrm.ts` are updated by hand to match — + see the comment at the top of that test for what it checks. + branch: 'gha/auto-sync-partner-intake-form' + base: 'master' + add-paths: | + apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json diff --git a/.prettierignore b/.prettierignore index 41a171fe018..d7aecae95d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,6 +14,8 @@ apps/www/public/images/* apps/www/public/changelog.md apps/www/public/changelog/*.md apps/www/.generated/* +# Written by apps/www/scripts/syncPartnerIntakeForm.mjs +apps/www/components/Partners/__generated__ apps/docs/**/generated/* apps/docs/examples/* examples/slack-clone/nextjs-slack-clone/full-schema.sql diff --git a/apps/www/app/partners/PartnersContent.tsx b/apps/www/app/partners/PartnersContent.tsx new file mode 100644 index 00000000000..912f419b7c5 --- /dev/null +++ b/apps/www/app/partners/PartnersContent.tsx @@ -0,0 +1,315 @@ +'use client' + +import { ArrowRight, Check } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button } from 'ui' + +import BackgroundPattern from '../../components/Partners/BackgroundPattern' +import DefaultLayout from '@/components/Layouts/Default' +import SectionContainer from '@/components/Layouts/SectionContainer' +import SectionHeading from '@/components/Layouts/SectionHeading' +import Panel from '@/components/Panel' +import BecomeAPartner from '@/components/Partners/BecomeAPartner' +import ProductHeaderCentered from '@/components/Sections/ProductHeaderCentered' +import pageData, { type PARTNER_TIER } from '@/data/partners' + +type FeaturedPartner = { slug: string; title: string; logo: string } + +interface Props { + featuredPartners: FeaturedPartner[] +} + +const FaqList = ({ items }: { items: { question: string; answer: string }[] }) => { + return ( + + {items.map((faq, i) => { + return ( +
+ + + {faq.question} + + +
+ {faq.answer} +
+
+
+
+ ) + })} +
+ ) +} + +export default function PartnersContent({ featuredPartners }: Props) { + const { heroSection } = pageData + + return ( + + {/* Hero */} +
+ + + + +
+ + {/* Three reasons to partner */} + + +
+ {pageData.reasons.map((reason) => ( + +

{reason.title}

+

{reason.description}

+
+ ))} +
+
+ + {/* Ways to partner */} +
+ +
+ + {pageData.waysToPartner.tiers.map((tier: PARTNER_TIER) => { + const timeToLaunch = (tier as { timeToLaunch?: string }).timeToLaunch + return ( + +
+

{tier.title}

+

+ {tier.description} +

+ {tier.cta && ( + + )} +
+
+
+
+ Best for +
+
{tier.bestFor}
+
+ {tier.whatYouGet && ( +
+
+ What you get +
+
+ {tier.whatYouGet} +
+
+ )} + {timeToLaunch && ( +
+
+ Time to launch +
+
{timeToLaunch}
+
+ )} +
+
+ ) + })} +
+
+
+ + {/* What partnership gets you */} + +
+ +
    + {pageData.benefits.items.map((item: any) => ( +
  • + + {item} +
  • + ))} +
+
+
+ + {/* How to apply */} +
+ +
+ + +
+
    + {pageData.howToApply.steps.map((step, i) => ( +
  1. + + + {String(i + 1).padStart(2, '0')} + +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
+
+
+ + {/* Featured partners */} + {featuredPartners.length > 0 && ( + +
+ Partners +

+ {pageData.featuredPartners.title} +

+

+ {pageData.featuredPartners.description} +

+
+
+ {[ + { + partners: featuredPartners.slice(0, Math.ceil(featuredPartners.length / 2)), + reverse: false, + }, + { + partners: featuredPartners.slice(Math.ceil(featuredPartners.length / 2)), + reverse: true, + }, + ].map((row, rowIdx) => ( +
+ {[...row.partners, ...row.partners].map((partner, i) => ( + = row.partners.length ? 'true' : undefined} + tabIndex={i >= row.partners.length ? -1 : undefined} + className="size-9 md:size-10 shrink-0 flex items-center justify-center backface-hidden" + > +
+ {partner.title} +
+ + ))} +
+ ))} +
+
+ +
+
+ )} + + {/* Ways you can integrate with Supabase */} +
+ + +
+ {pageData.integrationOptions.options.map((option) => ( + + +
+ {option.icon} +
+
+
{option.title}
+
+ {option.description} +
+ + Read the docs + + +
+
+ + ))} +
+
+
+ + {/* FAQ */} + + +
+ +
+
+ + {/* Inline partner intake form */} + +
+ ) +} diff --git a/apps/www/app/partners/catalog/IntegrationsContent.tsx b/apps/www/app/partners/catalog/IntegrationsContent.tsx index 071567bf0dd..64ea647745e 100644 --- a/apps/www/app/partners/catalog/IntegrationsContent.tsx +++ b/apps/www/app/partners/catalog/IntegrationsContent.tsx @@ -204,7 +204,7 @@ export default function IntegrationsContent({

@@ -462,7 +462,7 @@ export default function IntegrationsContent({ >

Interested in partnering with Supabase?

diff --git a/apps/www/app/partners/page.tsx b/apps/www/app/partners/page.tsx new file mode 100644 index 00000000000..8e9dbaa85c7 --- /dev/null +++ b/apps/www/app/partners/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from 'next' + +import PartnersContent from './PartnersContent' +import pageData from '@/data/partners' +import supabase from '@/lib/supabaseMisc' + +export const revalidate = 1800 + +const FEATURED_PARTNER_LIMIT = 30 + +type FeaturedPartner = { slug: string; title: string; logo: string } + +export const metadata: Metadata = { + title: pageData.metaTitle, + description: pageData.metaDescription, + openGraph: { + title: pageData.metaTitle, + description: pageData.metaDescription, + url: 'https://supabase.com/partners', + images: [{ url: 'https://supabase.com/images/og/integrations.png' }], + }, +} + +export default async function PartnersPage() { + const { data: partners } = await supabase + .from('partners') + .select('slug,title,logo') + .eq('approved', true) + .eq('type', 'technology') + .order('title') + + const all = (partners ?? []) as FeaturedPartner[] + const leadSlugs = pageData.featuredPartners.leadSlugs + const lead = leadSlugs + .map((slug) => all.find((p) => p.slug === slug)) + .filter((p): p is FeaturedPartner => Boolean(p)) + const rest = all.filter((p) => !leadSlugs.includes(p.slug)) + const featuredPartners = [...lead, ...rest].slice(0, FEATURED_PARTNER_LIMIT) + + return +} diff --git a/apps/www/components/Partners/BackgroundPattern.tsx b/apps/www/components/Partners/BackgroundPattern.tsx new file mode 100644 index 00000000000..7315b8aa421 --- /dev/null +++ b/apps/www/components/Partners/BackgroundPattern.tsx @@ -0,0 +1,32 @@ +import { cn } from 'ui' + +const BackgroundPattern = ({ className }: { className?: string }) => { + return ( +
+ + + + + + + + + {/* Soft brand-green radial glow from the top */} +
+
+ ) +} + +export default BackgroundPattern diff --git a/apps/www/components/Partners/BecomeAPartner.tsx b/apps/www/components/Partners/BecomeAPartner.tsx new file mode 100644 index 00000000000..ababf210ee3 --- /dev/null +++ b/apps/www/components/Partners/BecomeAPartner.tsx @@ -0,0 +1,32 @@ +import { Suspense } from 'react' + +import BackgroundPattern from './BackgroundPattern' +import PartnerIntakeForm from './PartnerIntakeForm' +import SectionContainer from '@/components/Layouts/SectionContainer' +import SectionHeading from '@/components/Layouts/SectionHeading' + +export default function BecomeAPartner() { + return ( +
+ + + + Apply to become a Supabase partner + + } + description="Tell us about your company, what you’re building and the program you're interested in. + We review every application and will reach out if there’s a good fit." + /> + + + + +
+ ) +} diff --git a/apps/www/components/Partners/PartnerIntakeForm.fields.ts b/apps/www/components/Partners/PartnerIntakeForm.fields.ts new file mode 100644 index 00000000000..39e02e70030 --- /dev/null +++ b/apps/www/components/Partners/PartnerIntakeForm.fields.ts @@ -0,0 +1,291 @@ +import type { MarketingFormField } from 'marketing/forms' + +/** + * Field data for PartnerIntakeForm, split into its own module (no `'use + * client'`, no runtime import of `marketing/forms`) so it can be imported by + * PartnerIntakeForm.sync.test.ts without pulling in MarketingForm's + * `'use server'` submission action and its `server-only` guard. + */ + +/** + * `partner_type` controls which conditional sections render. Values match + * HubSpot's `type_of_partners` picklist option values exactly (not our own + * slugs) because `staticFormCrm.ts` forwards this field straight through to + * HubSpot with no value translation — only the field *name* is mapped there. + * They're also referenced in `showWhen` rules below, in CTA query-string + * links (`data/partners/index.tsx`, URL-encoded since these contain spaces), + * and in the Notion `sendWhen` gating TODO in `apps/www/lib/staticFormCrm.ts`. + * + * HubSpot has 5 live options here, not 4: `marketplace` ("Technology + * Partners") and `technology` ("Tech Partner") are two distinct, currently + * selectable values — not a legacy duplicate — that happen to gate the same + * downstream fields (see the `in: [...]` showWhen rules below). + */ +export const PARTNER_TYPES = { + marketplace: 'Technology Partners', + technology: 'Tech Partner', + solutions: 'Solution Partners', + startup: 'Startup Partners', + other: 'Other', +} as const + +const partnerTypeOptions = [ + { + value: PARTNER_TYPES.marketplace, + label: 'Marketplace Partner — listing a tool that plugs into an existing Supabase project', + }, + { + value: PARTNER_TYPES.technology, + label: 'Technology Partner — building a technical integration', + }, + { + value: PARTNER_TYPES.solutions, + label: 'Solution Partner — agency, consultancy, or service provider', + }, + { + value: PARTNER_TYPES.startup, + label: 'Startup Partner — VC, accelerator, or ecosystem program', + }, + { + value: PARTNER_TYPES.other, + label: 'Other', + }, +] + +export const fields: MarketingFormField[] = [ + // ----- General ----- + { + name: 'first_name', + label: 'First name', + type: 'text', + required: true, + half: true, + placeholder: 'John', + }, + { + name: 'last_name', + label: 'Last name', + type: 'text', + required: true, + half: true, + placeholder: 'Doe', + }, + { + name: 'email', + label: 'Work email', + type: 'email', + required: true, + half: true, + placeholder: 'john.doe@company.com', + }, + { + name: 'company_name', + label: 'Company name', + type: 'text', + required: true, + half: true, + placeholder: 'Company Inc.', + }, + { + name: 'company_website', + label: 'Company website', + type: 'url', + required: true, + placeholder: 'https://', + }, + { + name: 'company_size', + label: 'How many people work at your company?', + type: 'select', + required: true, + placeholder: 'Select a range', + options: [ + { value: '1-5', label: '1-5' }, + { value: '6-10', label: '6-10' }, + { value: '11-20', label: '11-20' }, + { value: '21-50', label: '21-50' }, + { value: '51-100', label: '51-100' }, + { value: '100+', label: '100+' }, + ], + }, + { + name: 'partner_type', + label: 'What type of partnership are you interested in?', + type: 'select', + required: true, + placeholder: 'Select a partnership type', + options: partnerTypeOptions, + }, + + // ----- Technology Partners ----- + // Gated on BOTH the Marketplace and Technology Partner values — HubSpot + // shows this whole section for either (see the PARTNER_TYPES comment + // above). + { + name: 'solution_product_name', + label: 'Product or solution name', + type: 'text', + required: true, + showWhen: { field: 'partner_type', in: [PARTNER_TYPES.marketplace, PARTNER_TYPES.technology] }, + }, + { + name: 'integration_problem_description', + label: 'What problem does your integration solve for a Supabase customer?', + type: 'textarea', + required: true, + showWhen: { field: 'partner_type', in: [PARTNER_TYPES.marketplace, PARTNER_TYPES.technology] }, + }, + { + name: 'integration_docs_link', + label: 'Link to integration docs', + type: 'url', + required: true, + placeholder: 'https://', + showWhen: { field: 'partner_type', in: [PARTNER_TYPES.marketplace, PARTNER_TYPES.technology] }, + }, + { + name: 'integration_readiness', + label: 'Where are you in the integration journey?', + type: 'select', + required: true, + placeholder: 'Select an option', + // Option values match HubSpot's `integration_readiness` picklist exactly + // (including its straight apostrophes) — this field forwards to HubSpot + // by name with no value translation, so the value sent must be one of + // HubSpot's registered options. + options: [ + { + value: 'Our product already works with Supabase (users connect today)', + label: 'Our product already works with Supabase (users connect today)', + }, + { + value: 'We have a working integration or proof of concept', + label: 'We have a working integration or proof of concept', + }, + { + value: "We've scoped the integration but haven't built it yet", + label: "We've scoped the integration but haven't built it yet", + }, + { + value: "We're exploring, no integration work has started", + label: "We're exploring, no integration work has started", + }, + ], + showWhen: { field: 'partner_type', in: [PARTNER_TYPES.marketplace, PARTNER_TYPES.technology] }, + }, + { + name: 'partnerships_website', + label: 'Partnership or affiliate program page (optional)', + type: 'url', + placeholder: 'https://', + showWhen: { field: 'partner_type', in: [PARTNER_TYPES.marketplace, PARTNER_TYPES.technology] }, + }, + // ----- Solution Partners ----- + // As with `integration_readiness` above, every option `value` below + // matches HubSpot's picklist exactly since these fields forward straight + // through with no value translation. + { + name: 'services_offered', + label: 'Which services do you offer?', + type: 'checkbox-group', + required: true, + options: [ + { value: 'Build & implementation', label: 'Build & implementation' }, + { value: 'Database migration', label: 'Database migration' }, + { value: 'Auth & identity migration', label: 'Auth & identity migration' }, + { value: 'Technical consulting & advisory', label: 'Technical consulting & advisory' }, + { value: 'Managed services', label: 'Managed services' }, + { value: 'Reseller & VAR', label: 'Reseller & VAR' }, + { value: 'Other', label: 'Other' }, + ], + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.solutions }, + }, + { + name: 'operating_regions', + label: 'Where do you operate?', + type: 'checkbox-group', + required: true, + options: [ + { value: 'North America', label: 'North America' }, + { value: 'EMEA', label: 'EMEA' }, + { value: 'APAC', label: 'APAC' }, + { value: 'LATAM', label: 'LATAM' }, + { value: 'Global', label: 'Global' }, + ], + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.solutions }, + }, + { + name: 'supabase_postgres_experience', + label: 'How much Supabase / Postgres experience does your team have?', + type: 'select', + placeholder: 'Select an option', + options: [ + { + value: 'Yes — active client projects on Supabase', + label: 'Yes — active client projects on Supabase', + }, + { + value: 'Yes — internal or personal projects only', + label: 'Yes — internal or personal projects only', + }, + { + value: 'Postgres experience but not Supabase yet', + label: 'Postgres experience but not Supabase yet', + }, + { value: 'No experience yet', label: 'No experience yet' }, + ], + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.solutions }, + }, + { + name: 'client_types', + label: 'Who do you typically serve?', + type: 'checkbox-group', + required: true, + options: [ + { value: 'Startups', label: 'Startups' }, + { value: 'SMB', label: 'SMB' }, + { value: 'Mid-market', label: 'Mid-market' }, + { value: 'Enterprise', label: 'Enterprise' }, + { value: 'Mixed', label: 'Mixed' }, + ], + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.solutions }, + }, + + // ----- Startup Partners ----- + { + name: 'startup_program_type', + label: 'What best describes your program?', + type: 'select', + placeholder: 'Select an option', + options: [ + { value: 'VC', label: 'VC' }, + { value: 'Accelerator', label: 'Accelerator' }, + { value: 'Ecosystem', label: 'Ecosystem' }, + { value: 'Education', label: 'Education' }, + ], + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.startup }, + }, + { + name: 'startup_stage', + label: 'What stage of startups do you typically work with?', + type: 'text', + required: true, + description: 'Bootstrapped, pre-seed/seed, Series A–C, or Series D and beyond', + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.startup }, + }, + { + name: 'startup_capital_deployment', + label: 'Do you deploy capital into your portfolio or cohort? If so, how much?', + type: 'text', + showWhen: { field: 'partner_type', equals: PARTNER_TYPES.startup }, + }, + + // ----- General trailing field ----- + { + name: 'additional_details', + label: 'Any other details you’d like us to know?', + type: 'textarea', + rows: 4, + placeholder: 'Tell us more about your project, your team, and your goals for this partnership.', + }, +] diff --git a/apps/www/components/Partners/PartnerIntakeForm.sync.test.ts b/apps/www/components/Partners/PartnerIntakeForm.sync.test.ts new file mode 100644 index 00000000000..83a6dd92e07 --- /dev/null +++ b/apps/www/components/Partners/PartnerIntakeForm.sync.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' + +import hubspotSnapshotJson from './__generated__/partner-intake-form.hubspot.json' +import { fields } from './PartnerIntakeForm.fields' +import { staticFormCrmRegistry } from '@/lib/staticFormCrm' + +// staticFormCrm.ts is `import 'server-only'` gated; that guard throws when +// bundled for a client, but under plain Node/Vitest (no Next.js webpack +// aliasing) it throws unconditionally, so it must be stubbed for this test. +vi.mock('server-only', () => ({})) + +/** + * Cross-checks PartnerIntakeForm.tsx + staticFormCrm.ts against the + * generated HubSpot schema snapshot (apps/www/scripts/syncPartnerIntakeForm.mjs). + * + * This is the actual "can't silently drift" guarantee: re-run + * `pnpm run sync:partner-form` to refresh the snapshot from the live + * HubSpot form, and this test starts failing the moment the snapshot no + * longer matches the hand-authored form — forcing a deliberate update to + * PartnerIntakeForm.tsx / staticFormCrm.ts instead of a silent HubSpot + * submission failure. + */ + +interface HubSpotFieldSnapshot { + name: string + label: string + fieldType: string + required: boolean + options: { label: string; value: string }[] + description?: string + dependsOn?: { field: string; values: string[] } +} + +const hubspotSnapshot = hubspotSnapshotJson as { + portalId: number + formGuid: string + fields: HubSpotFieldSnapshot[] +} + +const crmConfig = staticFormCrmRegistry['partners/become-a-partner']?.hubspot +if (!crmConfig) { + throw new Error( + "Expected a hubspot CRM config for 'partners/become-a-partner' in staticFormCrm.ts" + ) +} + +const fieldMap = crmConfig.fieldMap ?? {} +const excludeFields = new Set(crmConfig.excludeFields ?? []) +const hubspotFieldsByName = new Map(hubspotSnapshot.fields.map((f) => [f.name, f])) + +// Company-object fields are bound via a `0-2/` prefix (see HubSpotClient) and +// aren't part of the Contact-field schema this endpoint returns — treat them +// as known rather than flagging them as unmapped. +const KNOWN_NON_CONTACT_FIELDS = new Set(['name', 'website']) + +/** The HubSpot property name a given app field forwards to. */ +function hubspotNameFor(appFieldName: string): string { + const mapped = fieldMap[appFieldName] ?? appFieldName + const slashIndex = mapped.indexOf('/') + return slashIndex === -1 ? mapped : mapped.slice(slashIndex + 1) +} + +describe('PartnerIntakeForm HubSpot sync', () => { + it('maps every required, non-excluded HubSpot field to an app field', () => { + const forwardedHubspotNames = new Set( + fields.filter((f) => !excludeFields.has(f.name)).map((f) => hubspotNameFor(f.name)) + ) + + const missing = hubspotSnapshot.fields + .filter((f) => f.required) + .map((f) => f.name) + .filter((name) => !forwardedHubspotNames.has(name)) + + expect(missing).toEqual([]) + }) + + it('binds company name and website to the Company object', () => { + // The `0-2/` prefix makes HubSpotClient submit these against the Company + // object rather than the Contact — dropping it silently mis-files them. + expect(fieldMap.company_name).toBe('0-2/name') + expect(fieldMap.company_website).toBe('0-2/website') + }) + + it('keeps requiredness and conditional visibility in sync for every mapped field', () => { + // A field HubSpot requires but we don't (or one we only show for the wrong + // partner types) makes the HubSpot submission fail for some users only. + const mismatches: string[] = [] + + for (const field of fields) { + if (excludeFields.has(field.name)) continue + + const hubspotName = hubspotNameFor(field.name) + const hubspotField = hubspotFieldsByName.get(hubspotName) + if (!hubspotField) continue + + if (Boolean(field.required) !== hubspotField.required) { + mismatches.push( + `${field.name}: required is ${Boolean(field.required)}, HubSpot's ${hubspotName} is ${hubspotField.required}` + ) + } + + const ours = field.showWhen + ? { + field: hubspotNameFor(field.showWhen.field), + values: [ + ...(field.showWhen.in ?? []), + ...(field.showWhen.equals !== undefined ? [field.showWhen.equals] : []), + ].sort(), + } + : undefined + const theirs = hubspotField.dependsOn + ? { field: hubspotField.dependsOn.field, values: [...hubspotField.dependsOn.values].sort() } + : undefined + + if (JSON.stringify(ours) !== JSON.stringify(theirs)) { + mismatches.push( + `${field.name}: shown when ${JSON.stringify(ours ?? 'always')}, HubSpot's ${hubspotName} when ${JSON.stringify(theirs ?? 'always')}` + ) + } + } + + expect(mismatches).toEqual([]) + }) + + it('forwards every non-excluded app field to a HubSpot property that actually exists', () => { + // An app field forwarded to a HubSpot property HubSpot doesn't define + // causes the *entire* HubSpot submission to fail (see HubSpotClient). + const unknown = fields + .filter((f) => !excludeFields.has(f.name)) + .map((f) => hubspotNameFor(f.name)) + .filter((name) => !KNOWN_NON_CONTACT_FIELDS.has(name)) + .filter((name) => !hubspotFieldsByName.has(name)) + + expect(unknown).toEqual([]) + }) + + it('keeps option values in exact sync for every mapped select/checkbox-group field', () => { + // Checked in BOTH directions: a value we send that HubSpot doesn't + // recognize, and — the bug that slipped through when this only checked + // one direction — a live HubSpot option we're not offering at all (e.g. + // a "Marketplace Partner" option nobody could ever select). + const mismatches: string[] = [] + + for (const field of fields) { + if (field.type !== 'select' && field.type !== 'checkbox-group') continue + if (excludeFields.has(field.name)) continue + + const hubspotName = hubspotNameFor(field.name) + const hubspotField = hubspotFieldsByName.get(hubspotName) + if (!hubspotField || hubspotField.options.length === 0) continue + + const ourValues = new Set(field.options.map((o) => o.value)) + const hubspotValues = new Set(hubspotField.options.map((o) => o.value)) + + for (const value of ourValues) { + if (!hubspotValues.has(value)) { + mismatches.push(`${field.name}: "${value}" is not a valid ${hubspotName} option`) + } + } + for (const value of hubspotValues) { + if (!ourValues.has(value)) { + mismatches.push(`${field.name}: missing HubSpot's "${value}" option for ${hubspotName}`) + } + } + } + + expect(mismatches).toEqual([]) + }) +}) diff --git a/apps/www/components/Partners/PartnerIntakeForm.tsx b/apps/www/components/Partners/PartnerIntakeForm.tsx new file mode 100644 index 00000000000..f3f9ef9fc1b --- /dev/null +++ b/apps/www/components/Partners/PartnerIntakeForm.tsx @@ -0,0 +1,33 @@ +'use client' + +import { MarketingForm } from 'marketing/forms' +import { parseAsString, useQueryState } from 'nuqs' + +import { fields } from './PartnerIntakeForm.fields' + +// Keep in sync with the thank-you message on the HubSpot form. +const successMessage = `We appreciate your interest in partnering with Supabase. Our team reviews every submission, and if there’s a good fit with the program you’ve expressed interest in, we’ll be in touch to discuss next steps. + +If you’re a technology partner looking to get a head start, you can begin building your OAuth integration today using our [integration guide](/docs/guides/integrations/build-a-supabase-oauth-integration).` + +interface PartnerIntakeFormProps { + className?: string +} + +export default function PartnerIntakeForm({ className }: PartnerIntakeFormProps) { + const [partnerType] = useQueryState('partner_type', parseAsString.withDefault('')) + + return ( + + ) +} diff --git a/apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json b/apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json new file mode 100644 index 00000000000..e301b1138e5 --- /dev/null +++ b/apps/www/components/Partners/__generated__/partner-intake-form.hubspot.json @@ -0,0 +1,393 @@ +{ + "portalId": 19953346, + "formGuid": "652a3eed-8d06-45fb-9376-f5aeb0e3f5dc", + "submitText": "Submit", + "fields": [ + { + "name": "firstname", + "label": "First name", + "fieldType": "text", + "required": true, + "options": [] + }, + { + "name": "lastname", + "label": "Last name", + "fieldType": "text", + "required": true, + "options": [] + }, + { + "name": "email", + "label": "Email", + "fieldType": "text", + "required": true, + "options": [] + }, + { + "name": "name", + "label": "Company name", + "fieldType": "text", + "required": true, + "options": [] + }, + { + "name": "website", + "label": "Company website", + "fieldType": "text", + "required": true, + "options": [] + }, + { + "name": "how_many_people_are_on_your_team_", + "label": "How many people work at your company?", + "fieldType": "select", + "required": true, + "options": [ + { + "label": "1-5", + "value": "1-5" + }, + { + "label": "6-10", + "value": "6-10" + }, + { + "label": "11-20", + "value": "11-20" + }, + { + "label": "21-50", + "value": "21-50" + }, + { + "label": "51-100", + "value": "51-100" + }, + { + "label": "100+", + "value": "100+" + } + ] + }, + { + "name": "type_of_partners", + "label": "Type of Partner", + "fieldType": "select", + "required": true, + "options": [ + { + "label": "Marketplace Partners - I'm building a tool that plugs into an existing Supabase project.", + "value": "Technology Partners" + }, + { + "label": "Technology Partners - I'm building a tool that plugs into an existing Supabase project.", + "value": "Tech Partner" + }, + { + "label": "Solution Partners - I’m helping my customers build products using Supabase as a solution partner.", + "value": "Solution Partners" + }, + { + "label": "Startup Partners - I’m helping startups access Supabase.", + "value": "Startup Partners" + }, + { + "label": "Other", + "value": "Other" + } + ] + }, + { + "name": "solution_you_provide", + "label": "Solution / Product name", + "fieldType": "textarea", + "required": true, + "options": [], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Technology Partners", + "Tech Partner" + ] + } + }, + { + "name": "what_problem_does_your_integration_solve_for_a_supabase_customer", + "label": "What problem does your integration solve for a Supabase customer?", + "fieldType": "textarea", + "required": true, + "options": [], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Technology Partners", + "Tech Partner" + ] + } + }, + { + "name": "link_to_documentation", + "label": "Link to integration documentation", + "fieldType": "text", + "required": true, + "options": [], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Technology Partners", + "Tech Partner" + ] + } + }, + { + "name": "integration_readiness", + "label": "Integration readiness", + "fieldType": "select", + "required": true, + "options": [ + { + "label": "Our product already works with Supabase (users connect today)", + "value": "Our product already works with Supabase (users connect today)" + }, + { + "label": "We have a working integration or proof of concept", + "value": "We have a working integration or proof of concept" + }, + { + "label": "We've scoped the integration but haven't built it yet", + "value": "We've scoped the integration but haven't built it yet" + }, + { + "label": "We're exploring, no integration work has started", + "value": "We're exploring, no integration work has started" + } + ], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Technology Partners", + "Tech Partner" + ] + } + }, + { + "name": "describe_your_program", + "label": "Describe your program", + "fieldType": "select", + "required": false, + "options": [ + { + "label": "VC", + "value": "VC" + }, + { + "label": "Accelerator", + "value": "Accelerator" + }, + { + "label": "Ecosystem", + "value": "Ecosystem" + }, + { + "label": "Education", + "value": "Education" + } + ], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Startup Partners" + ] + } + }, + { + "name": "what_services_do_you_offer", + "label": "What services do you offer?", + "fieldType": "checkbox", + "required": true, + "options": [ + { + "label": "Build & implementation", + "value": "Build & implementation" + }, + { + "label": "Database migration", + "value": "Database migration" + }, + { + "label": "Auth & identity migration", + "value": "Auth & identity migration" + }, + { + "label": "Technical consulting & advisory", + "value": "Technical consulting & advisory" + }, + { + "label": "Managed services", + "value": "Managed services" + }, + { + "label": "Reseller & VAR", + "value": "Reseller & VAR" + }, + { + "label": "Other", + "value": "Other" + } + ], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Solution Partners" + ] + } + }, + { + "name": "which_regions_do_you_operate_in", + "label": "Which regions do you operate in?", + "fieldType": "checkbox", + "required": true, + "options": [ + { + "label": "North America", + "value": "North America" + }, + { + "label": "EMEA", + "value": "EMEA" + }, + { + "label": "APAC", + "value": "APAC" + }, + { + "label": "LATAM", + "value": "LATAM" + }, + { + "label": "Global", + "value": "Global" + } + ], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Solution Partners" + ] + } + }, + { + "name": "do_you_have_existing_supabase_or_postgres_experience", + "label": "Do you have existing Supabase or Postgres experience?", + "fieldType": "select", + "required": false, + "options": [ + { + "label": "Yes — active client projects on Supabase", + "value": "Yes — active client projects on Supabase" + }, + { + "label": "Yes — internal or personal projects only", + "value": "Yes — internal or personal projects only" + }, + { + "label": "Postgres experience but not Supabase yet", + "value": "Postgres experience but not Supabase yet" + }, + { + "label": "No experience yet", + "value": "No experience yet" + } + ], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Solution Partners" + ] + } + }, + { + "name": "what_type_of_clients_do_you_typically_work_with", + "label": "What type of clients do you typically work with?", + "fieldType": "checkbox", + "required": true, + "options": [ + { + "label": "Startups", + "value": "Startups" + }, + { + "label": "SMB", + "value": "SMB" + }, + { + "label": "Mid-market", + "value": "Mid-market" + }, + { + "label": "Enterprise", + "value": "Enterprise" + }, + { + "label": "Mixed", + "value": "Mixed" + } + ], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Solution Partners" + ] + } + }, + { + "name": "can_you_share_your_companys_partnerships_website", + "label": "Can you share your company’s partnerships website?", + "fieldType": "text", + "required": false, + "options": [], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Technology Partners", + "Tech Partner" + ] + } + }, + { + "name": "what_stage_of_startups_do_you_typically_work_with", + "label": "What stage of startups do you typically work with?", + "fieldType": "text", + "required": true, + "options": [], + "description": "Bootstrapped, Pre-seed/Seed, Series A - C, Series D and beyond", + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Startup Partners" + ] + } + }, + { + "name": "do_you_deploy_capital_into_your_portfoliocohort_if_so_how_much", + "label": "Do you deploy capital into your portfolio/cohort? If so how much?", + "fieldType": "text", + "required": false, + "options": [], + "dependsOn": { + "field": "type_of_partners", + "values": [ + "Startup Partners" + ] + } + }, + { + "name": "any_other_details_youd_like_us_to_know", + "label": "Any other details you'd like us to know?", + "fieldType": "textarea", + "required": false, + "options": [] + } + ] +} diff --git a/apps/www/data/partners/index.tsx b/apps/www/data/partners/index.tsx index 4eb558f294e..06393686cc1 100644 --- a/apps/www/data/partners/index.tsx +++ b/apps/www/data/partners/index.tsx @@ -1,144 +1,242 @@ -import { Compass, DollarSign, Globe, TrendingUp } from 'lucide-react' +import { + ArrowRight, + ArrowUpRight, + Database, + FileText, + Globe, + Key, + Plug, + Puzzle, + Webhook, +} from 'lucide-react' -export default { +import { PARTNER_TYPES } from '@/components/Partners/PartnerIntakeForm.fields' + +export const PARTNER_FORM_ANCHOR = '#become-a-partner' + +const partnersPageData = { metaTitle: 'Partner with Supabase', - metaDescription: 'Become a Supabase Partner and enable new business opportunities.', + metaDescription: + 'Reach 9 million developers. Ship integrations and offer services that help businesses build on Supabase.', heroSection: { title: 'Partners', - h1: Partner with Supabase, - subheader: ( - <> - Apply to the Partners program to list your integration
in - our marketplace and grow your business. - - ), - image: ( -
-
- -
-
- ), + h1: <>Build with Supabase. Reach 9 million developers., + subheader: <>Ship integrations and offer services that help businesses build on Supabase., cta: { - label: 'Become a Partner', - link: 'https://forms.supabase.com/partner', + label: 'Apply as a Partner', + link: PARTNER_FORM_ANCHOR, }, secondaryCta: { label: 'Partner Catalog', link: '/partners/catalog', + icon: , }, }, - oAuthApp: { - steps: [ + reasonsSection: { + eyebrow: 'Why partner', + title: 'Three reasons to partner with Supabase', + description: + 'A growing developer audience, the deep extensibility of Postgres, and a partner program our customers trust.', + }, + reasons: [ + { + title: 'Distribution', + description: + '9 million developers and growing. Your product, in front of the ones already shipping.', + }, + { + title: 'Technical fit', + description: + 'Every Supabase project is real Postgres. Standard interfaces, no proprietary glue, no rewrite every six months.', + }, + { + title: 'Trust', + description: + 'We list partners we use and partners that actually ship. The catalog is curated, which is why developers read it.', + }, + ], + waysToPartner: { + title: 'Ways to partner', + tiers: [ { - title: 'Register App', - text: 'An OAuth app first needs to be registered with Supabase', + title: 'Integration Partner', + description: ( + <> + You've built something that works with Supabase: a tool, extension, connector, or + service. We list it, co-market it, and put it in front of developers who are actively + composing their stack. + + ), + cta: { + label: 'Apply as Integration Partner', + link: `?partner_type=${encodeURIComponent(PARTNER_TYPES.technology)}${PARTNER_FORM_ANCHOR}`, + }, + bestFor: + 'developer tools, SaaS products, hosting, observability, auth, billing, and anything else developers add to a Postgres project.', }, { - title: 'Add OAuth2 Support', - text: "Use the OAuth2 protocol to access a user's organization or project", - }, - { - title: 'Receive Tokens', - text: "You'll receive a new access and refresh token", - }, - { - title: 'Control Projects', - text: 'Use Supabase REST API to control projects and other settings', + title: 'Solution Partner', + description: 'You build on Supabase on behalf of clients.', + cta: { + label: 'Apply as Solution Partner', + link: `?partner_type=${encodeURIComponent(PARTNER_TYPES.solutions)}${PARTNER_FORM_ANCHOR}`, + }, + bestFor: 'agencies, consultancies, and service providers.', }, ], }, - featureBlocks: [ - { - title: 'Technical support', - description: 'Access technical support to back your integrations and customer projects.', - icon: , + benefits: { + title: 'What your Supabase partnership gets you', + description: 'Distribution to developers already shipping on Postgres, and much more.', + items: [ + <> + + A listing in the Partner Catalog: + {' '} + Searchable by category, visible to developers actively evaluating tools + , + <> + Co-marketing: Supabase's + highest-traffic moments like Launch Weeks, with blog, social, and Discord reach + , + <> + + A direct line to the product team: + {' '} + Actual engineers who know the integration surface, not a support queue + , + <> + + Something your sales team can use: + {' '} + A Supabase-verified listing is a trust signal with developers who already know us + , + ], + }, + howToApply: { + title: 'Ways to apply', + steps: [ + { + title: 'Apply', + description: + "This takes five minutes. Tell us what you build, who it's for, and how it connects to Supabase.", + }, + { + title: 'Review', + description: 'This takes a week. It’s faster if the integration is already live.', + }, + { + title: 'Launch', + description: 'We help with the listing, the integration, and the launch moment.', + }, + ], + cta: { + label: 'Apply to partner with Supabase', + link: PARTNER_FORM_ANCHOR, }, - { - title: 'Expand your ecosystem', - description: 'Offer your own products and services to Supabase customers.', - icon: , + }, + featuredPartners: { + title: 'In good company', + description: 'Companies building with Supabase, integrating with Supabase, or both.', + // Order to lead the logo wall with at launch. + leadSlugs: ['grafana', 'stripe', 'resend', 'doppler', 'aikido', 'vanta', 'cipherstash'], + }, + integrationOptions: { + title: 'Ways you can integrate with Supabase', + options: [ + { + title: 'OAuth App', + description: 'Control organizations and projects programmatically.', + href: 'https://supabase.com/docs/guides/integrations/build-a-supabase-integration', + icon: , + }, + { + title: 'Management API', + description: 'Manage projects, branches, secrets, and deployments.', + href: 'https://supabase.com/docs/reference/api/introduction', + icon: , + }, + { + title: 'Foreign Data Wrapper', + description: 'Expose your data as Postgres tables developers can query with SQL.', + href: 'https://supabase.com/docs/guides/database/extensions/wrappers/overview', + icon: , + }, + { + title: 'Postgres extension', + description: 'Add types, functions, or operators to every Supabase project.', + href: 'https://supabase.com/docs/guides/database/extensions', + icon: , + }, + { + title: 'Log drains', + description: 'Receive project logs from Pro, Team, and Enterprise projects.', + href: 'https://supabase.com/docs/guides/platform/log-drains', + icon: , + }, + { + title: 'Auth provider', + description: 'Act as a third-party identity provider over OIDC or SAML.', + href: 'https://supabase.com/docs/guides/auth/sso', + icon: , + }, + { + title: 'Postgres connection', + description: 'Anything that speaks Postgres works as-is.', + href: 'https://supabase.com/docs/guides/database/connecting-to-postgres', + icon: , + }, + ], + }, + faq: { + title: 'Frequently asked questions', + items: [ + { + question: 'Is there a fee to be listed?', + answer: 'No. The partner catalog is free.', + }, + { + question: 'Can I apply before my integration is built?', + answer: + "Yes, though your application moves faster if the integration is already live. Tell us what you're building and where you are, and we’ll go from there.", + }, + { + question: 'How long is review?', + answer: 'Usually a week. We get back to you either way.', + }, + { + question: 'What’s the difference between an Integration Partner and a Solution Partner?', + answer: + 'Integration partners build technical products that connect to Supabase. Solution partners are agencies or consultancies that build on Supabase for clients.', + }, + { + question: 'Do I need to be a Supabase customer?', + answer: + 'No, but your integration should work with Supabase projects. Most partners build on a free plan to start.', + }, + ], + }, + finalCta: { + title: 'Partner with Supabase.', + cta: { + label: 'Become a partner', + link: PARTNER_FORM_ANCHOR, }, - { - title: 'Business growth', - description: 'Explore new revenue streams and growth potential.', - icon: , - }, - { - title: 'Scale with us', - description: 'Scale automatically with the power of open-source Postgres technology.', - icon: , - }, - ], - featuredApps: [ - { - name: 'Arengu', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/arengu/arengu_logo.jpeg', - }, - { - name: 'Auth0', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/auth0/auth0_dark.png?t=2023-07-19T19%3A13%3A04.189Z', - }, - { - name: 'Appsmith', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/appsmith/appsmith-logo.png', - }, - { - name: 'CALDA', - type: 'experts', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/experts/calda/calda_logo.jpeg', - }, - { - name: 'Morrow', - type: 'experts', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/experts/morrow/morrow-logo.png', - }, - { - name: 'Voypost', - type: 'experts', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/experts/voypost/33024474.png', - }, - { - name: 'Vercel', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/vercel/vercel-icon.jpeg', - }, - { - name: 'Prisma', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/prisma/prisma-icon.png', - }, - { - name: 'Cloudflare-workers', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/cloudflare-integration/cloudflare_workers_logo.png?t=2023-07-21T11%3A07%3A47.005Z', - }, - { - name: 'Codesandbox', - type: 'integration', - logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/codesandbox/codesandbox_logo.jpeg', - }, - ], + icon: , + }, } + +export type PARTNER_TIER = { + title: string + description: React.ReactNode + bestFor: string + whatYouGet?: string + timeToLaunch?: string + cta?: { + label: string + link: string + icon?: React.ReactNode + } +} + +export default partnersPageData diff --git a/apps/www/lib/registerFormCrm.ts b/apps/www/lib/registerFormCrm.ts index d18d8a097b4..763bb063a9c 100644 --- a/apps/www/lib/registerFormCrm.ts +++ b/apps/www/lib/registerFormCrm.ts @@ -3,6 +3,7 @@ import 'server-only' import { setFormCrmResolver } from 'marketing' import { getGoPageBySlug } from './go' +import { staticFormCrmRegistry } from './staticFormCrm' /** * Wire the marketing form server action to look up the trusted CRM config for @@ -12,6 +13,9 @@ import { getGoPageBySlug } from './go' */ export function registerFormCrmResolver() { setFormCrmResolver(({ slug, formId }) => { + const staticEntry = staticFormCrmRegistry[`${slug}/${formId}`] + if (staticEntry) return staticEntry + const page = getGoPageBySlug(slug) if (!page || !('sections' in page) || !page.sections) return undefined diff --git a/apps/www/lib/staticFormCrm.ts b/apps/www/lib/staticFormCrm.ts new file mode 100644 index 00000000000..38788586a64 --- /dev/null +++ b/apps/www/lib/staticFormCrm.ts @@ -0,0 +1,41 @@ +import 'server-only' + +import type { GoFormCrmConfig } from 'marketing' + +/** + * CRM config for forms on hand-authored pages that aren't part of the /go + * page registry. Keyed by `${slug}/${formId}` to match the shape resolved + * from `getGoPageBySlug` for consistency. See registerFormCrm.ts. + */ +export const staticFormCrmRegistry: Record = { + 'partners/become-a-partner': { + hubspot: { + formGuid: process.env.HUBSPOT_PARTNER_INTAKE_FORM_GUID ?? '', + fieldMap: { + first_name: 'firstname', + last_name: 'lastname', + company_size: 'how_many_people_are_on_your_team_', + partner_type: 'type_of_partners', + // Bound to the Company object's `name`/`website` properties (not + // Contact) in the HubSpot form — the `0-2/` prefix tells + // HubSpotClient to submit these against objectTypeId '0-2'. + company_name: '0-2/name', + company_website: '0-2/website', + solution_product_name: 'solution_you_provide', + integration_problem_description: + 'what_problem_does_your_integration_solve_for_a_supabase_customer', + integration_docs_link: 'link_to_documentation', + partnerships_website: 'can_you_share_your_companys_partnerships_website', + services_offered: 'what_services_do_you_offer', + operating_regions: 'which_regions_do_you_operate_in', + supabase_postgres_experience: 'do_you_have_existing_supabase_or_postgres_experience', + client_types: 'what_type_of_clients_do_you_typically_work_with', + startup_program_type: 'describe_your_program', + startup_stage: 'what_stage_of_startups_do_you_typically_work_with', + startup_capital_deployment: + 'do_you_deploy_capital_into_your_portfoliocohort_if_so_how_much', + additional_details: 'any_other_details_youd_like_us_to_know', + }, + }, + }, +} diff --git a/apps/www/package.json b/apps/www/package.json index 189a361046b..0184c510cb0 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -16,8 +16,10 @@ "typecheck": "pnpm run content:build:core && tsc --noEmit", "content:build:core": "node scripts/generateStaticContent.mjs && node scripts/generateMdContent.mjs", "content:build": "pnpm run content:build:core && node scripts/fetchAgentSkills.mjs", + "sync:partner-form": "node scripts/syncPartnerIntakeForm.mjs", "test": "vitest --run", "test:watch": "vitest watch", + "test:partner-form-sync": "vitest run --config vitest.sync.config.ts", "postbuild": "node ./internals/generate-sitemap.mjs && ./../../scripts/upload-static-assets.sh" }, "dependencies": { diff --git a/apps/www/pages/partners/index.tsx b/apps/www/pages/partners/index.tsx deleted file mode 100644 index 0cf2b6117ea..00000000000 --- a/apps/www/pages/partners/index.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useBreakpoint } from 'common' -import { Code } from 'lucide-react' -import { NextSeo } from 'next-seo' -import Image from 'next/image' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { Badge, Button } from 'ui' -import { TextLink } from 'ui-patterns/TextLink' - -import DefaultLayout from '@/components/Layouts/Default' -import SectionContainer from '@/components/Layouts/SectionContainer' -import Panel from '@/components/Panel' -import ProductHeaderCentered from '@/components/Sections/ProductHeaderCentered' -import pageData from '@/data/partners' -import { range } from '@/lib/helpers' - -const Partners = () => { - const router = useRouter() - const isSm = useBreakpoint() - - return ( - <> - - -
- - - -
-
-
- - - - - - - - - - -
- - Supabase icon -
- {range(0, 3).map((_) => ( -
- {pageData.featuredApps.map((app) => ( -
- {app.name} -
- ))} -
- ))} -
-
-
- -
-
-

- Explore our marketplace -

-

- Discover how technology and consulting partners are already working with Supabase. -

- -
-
- -
- - - -
- -
-

Integrations

-

- Use your favorite tools with Supabase. -

-
- -
-
-
-
- -
- - -

- Partner benefits -

-
- {pageData.featureBlocks.map((item, i) => { - return ( -
-
- {item.icon ? item.icon : } -
- -
-

{item.title}

-

{item.description}

-
-
- ) - })} -
-
-
- -
-
- Beta -

- Publish an OAuth App -

-

- Supabase lets you build a third-party app that can control organizations or - projects programmatically. -

- -
-
- Register app via API -
-
-
-
- -
-

- Reach out to partner with Supabase -

-
- -
-
-
- - - ) -} - -export default Partners diff --git a/apps/www/scripts/syncPartnerIntakeForm.mjs b/apps/www/scripts/syncPartnerIntakeForm.mjs new file mode 100644 index 00000000000..5e0b1d28390 --- /dev/null +++ b/apps/www/scripts/syncPartnerIntakeForm.mjs @@ -0,0 +1,109 @@ +// @ts-check + +/** + * Fetches the live "Become a Partner" HubSpot form schema and writes a + * normalized snapshot to __generated__/partner-intake-form.hubspot.json. + * + * HubSpot's own embeddable-form widget (and share.hsforms.com links) loads + * the form definition from a public, keyless JSON(P) endpoint — the same one + * used here. `portalId`/`formGuid` aren't secrets: they're recoverable from + * any public share link or the embed page's own source, so it's safe to + * check them into the repo. They should match the (secret-adjacent, but not + * actually secret) HUBSPOT_PORTAL_ID / HUBSPOT_PARTNER_INTAKE_FORM_GUID env + * vars used at submission time in apps/www/lib/staticFormCrm.ts. + * + * This snapshot is NOT rendered directly — PartnerIntakeForm.tsx stays + * hand-authored for curated copy/layout. PartnerIntakeForm.sync.test.ts + * cross-checks the hand-authored form against this file so drift between + * the two fails CI instead of silently breaking submissions. Re-run this + * script (`pnpm run sync:partner-form`) whenever that test starts failing + * after a snapshot update, and reconcile PartnerIntakeForm.tsx / staticFormCrm.ts + * by hand. + */ + +import { promises as fs } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUT_DIR = join(__dirname, '..', 'components', 'Partners', '__generated__') +const OUT_FILE = join(OUT_DIR, 'partner-intake-form.hubspot.json') + +// Decoded from the share link https://share.hsforms.com/1ZSo-7Y0GRfuTdvWusOP13Abvo3m +const PORTAL_ID = process.env.HUBSPOT_PORTAL_ID || '19953346' +const FORM_GUID = + process.env.HUBSPOT_PARTNER_INTAKE_FORM_GUID || '652a3eed-8d06-45fb-9376-f5aeb0e3f5dc' + +async function fetchFormSchema(portalId, formGuid) { + const url = `https://forms.hubspot.com/embed/v3/form/${portalId}/${formGuid}?callback=cb` + const res = await fetch(url, { headers: { 'User-Agent': 'supabase-www-build' } }) + if (!res.ok) throw new Error(`GET ${url} → ${res.status}`) + + const body = await res.text() + const jsonpMatch = body.match(/^cb\((.*)\)$/s) + if (!jsonpMatch) + throw new Error(`Unexpected response shape from HubSpot embed endpoint: ${body.slice(0, 200)}`) + + const data = JSON.parse(jsonpMatch[1]) + if (!data.form) throw new Error(`HubSpot response missing "form": ${body.slice(0, 200)}`) + return data.form +} + +function normalizeField(field, dependsOn) { + const normalized = { + name: field.name, + label: field.label, + fieldType: field.fieldType, + required: Boolean(field.required), + options: (field.options ?? []).map((o) => ({ label: o.label, value: o.value })), + } + if (field.description) normalized.description = field.description + if (dependsOn) normalized.dependsOn = dependsOn + return normalized +} + +/** + * Flattens `formFieldGroups[].fields[]` plus every nested + * `dependentFieldFilters[].dependentFormField` into one flat list, recording + * which field/values a dependent field is conditional on. + */ +function flattenFields(form) { + const fields = [] + for (const group of form.formFieldGroups ?? []) { + for (const field of group.fields ?? []) { + fields.push(normalizeField(field)) + for (const dep of field.dependentFieldFilters ?? []) { + const trigger = dep.filters?.[0] + fields.push( + normalizeField(dep.dependentFormField, { + field: field.name, + values: trigger?.strValues ?? [], + }) + ) + } + } + } + return fields +} + +async function main() { + console.log(`Fetching HubSpot form ${FORM_GUID} (portal ${PORTAL_ID})`) + const form = await fetchFormSchema(PORTAL_ID, FORM_GUID) + + const snapshot = { + portalId: form.portalId, + formGuid: form.guid, + submitText: form.submitText, + fields: flattenFields(form), + } + + await fs.mkdir(OUT_DIR, { recursive: true }) + await fs.writeFile(OUT_FILE, JSON.stringify(snapshot, null, 2) + '\n') + + console.log(`Wrote ${snapshot.fields.length} fields → ${OUT_FILE}`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index acbc04712ad..f0a1a1b59dc 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -70,6 +70,8 @@ "HUBSPOT_PORTAL_ID", "HUBSPOT_ENTERPRISE_FORM_GUID", "HUBSPOT_PARTNERSHIP_FORM_GUID", + "HUBSPOT_PARTNER_INTAKE_FORM_GUID", + "NOTION_FORMS_API_KEY", "OPENAI_API_KEY", "EMAIL_REPORT_SLACK_WEBHOOK", "npm_lifecycle_event", diff --git a/apps/www/vitest.config.ts b/apps/www/vitest.config.ts index 7d4eaa16eff..9e88f3d21fa 100644 --- a/apps/www/vitest.config.ts +++ b/apps/www/vitest.config.ts @@ -10,7 +10,18 @@ export default defineConfig({ test: { name: 'unit', include: [...configDefaults.include, '../../packages/common/sentry.test.ts'], - exclude: [...configDefaults.exclude, '.next/*', 'lib/sentry-capture.test.tsx'], + exclude: [ + ...configDefaults.exclude, + '.next/*', + 'lib/sentry-capture.test.tsx', + // Deliberately NOT part of the regular `pnpm test` run: this checks + // PartnerIntakeForm against a live HubSpot snapshot, and a HubSpot-side + // change (nothing to do with any given PR) shouldn't be able to fail + // CI for unrelated apps/www work. It runs via its own dedicated + // `test:partner-form-sync` script instead — see vitest.sync.config.ts + // and .github/workflows/www-partner-form-sync-check.yml. + 'components/Partners/PartnerIntakeForm.sync.test.ts', + ], }, }, sentryConfig, diff --git a/apps/www/vitest.sync.config.ts b/apps/www/vitest.sync.config.ts new file mode 100644 index 00000000000..2d296581471 --- /dev/null +++ b/apps/www/vitest.sync.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' + +/** + * Runs only PartnerIntakeForm.sync.test.ts, which the default vitest.config.ts + * deliberately excludes from `pnpm test` — see the comment there. Used by + * `pnpm run test:partner-form-sync` and by the path-filtered + * www-partner-form-sync-check.yml workflow, not by the regular www-tests.yml + * suite that gates every apps/www PR. + */ +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + include: ['components/Partners/PartnerIntakeForm.sync.test.ts'], + }, +}) diff --git a/packages/marketing/package.json b/packages/marketing/package.json index f87045fe20c..b650f3aad42 100644 --- a/packages/marketing/package.json +++ b/packages/marketing/package.json @@ -6,6 +6,7 @@ "types": "./index.ts", "exports": { ".": "./index.ts", + "./forms": "./src/forms/index.ts", "./crm": "./src/crm/index.ts" }, "scripts": { diff --git a/packages/marketing/src/crm/hubspot.ts b/packages/marketing/src/crm/hubspot.ts index c5f5619e9fa..2c2a8288caf 100644 --- a/packages/marketing/src/crm/hubspot.ts +++ b/packages/marketing/src/crm/hubspot.ts @@ -45,11 +45,23 @@ export class HubSpotClient { consent?: string } ): Promise { - const hubspotFields: HubSpotField[] = Object.entries(fields).map(([name, value]) => ({ - objectTypeId: '0-1', - name, - value, - })) + // Field keys are plain HubSpot Contact property names (e.g. `firstname`) + // by default. A key may instead be prefixed `{objectTypeId}/{property}` + // (e.g. `0-2/name` for the Company object's `name` property) to target a + // property on an object other than Contact — this mirrors how HubSpot + // itself identifies associated-object fields in its own API error + // messages ("Error in 'fields.0-2/name'..."). + const hubspotFields: HubSpotField[] = Object.entries(fields).map(([key, value]) => { + const slashIndex = key.indexOf('/') + if (slashIndex === -1) { + return { objectTypeId: '0-1', name: key, value } + } + return { + objectTypeId: key.slice(0, slashIndex), + name: key.slice(slashIndex + 1), + value, + } + }) const body: HubSpotSubmission = { fields: hubspotFields, diff --git a/packages/marketing/src/forms/MarketingForm.tsx b/packages/marketing/src/forms/MarketingForm.tsx index c16d982c959..41514101e4c 100644 --- a/packages/marketing/src/forms/MarketingForm.tsx +++ b/packages/marketing/src/forms/MarketingForm.tsx @@ -16,7 +16,12 @@ import { import type { z } from 'zod' import { submitFormAction } from '../go/actions/submitForm' -import { formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas' +import { formFieldSchema, type GoFormCrmConfig } from '../go/schemas' +import { evaluateShowWhen } from '../go/showWhen' + +/** CRM fan-out config used by components that define their CRM inline (e.g. PartnerIntakeForm). + * Once a form's `formRef` is registered on the server, prefer that pattern for security. */ +export type MarketingFormCrmConfig = GoFormCrmConfig /** Input-shape field type — fields with Zod defaults (`half`, `required`) are optional here. */ export type MarketingFormField = z.input @@ -31,21 +36,6 @@ export interface MarketingFormRef { formId: string } -/** - * Evaluate a `showWhen` rule against the current form values. All supplied - * criteria must pass (AND). Missing values are treated as the empty string. - */ -function evaluateShowWhen(showWhen: GoFormFieldShowWhen, values: Record): boolean { - const value = values[showWhen.field] ?? '' - if (showWhen.equals !== undefined && value !== showWhen.equals) return false - if (showWhen.notEquals !== undefined && value === showWhen.notEquals) return false - if (showWhen.in !== undefined && !showWhen.in.includes(value)) return false - if (showWhen.notIn !== undefined && showWhen.notIn.includes(value)) return false - if (showWhen.truthy === true && value === '') return false - if (showWhen.truthy === false && value !== '') return false - return true -} - export interface MarketingFormProps { /** Form fields. The submit handler builds the payload from these by `name`. */ fields: MarketingFormField[] @@ -72,6 +62,13 @@ export interface MarketingFormProps { card?: boolean /** Extra class names applied to the outer wrapper. */ className?: string + /** Pre-populate specific fields by name. Values are applied on first render only. */ + initialValues?: Record + /** + * Inline CRM config. Accepted for type compatibility; actual fan-out requires + * a registered `formRef`. Wire up `formRef` once the form GUIDs are confirmed. + */ + crm?: MarketingFormCrmConfig } type SubmitState = 'idle' | 'loading' | 'success' | 'error' @@ -130,6 +127,7 @@ function FieldInput({ ) case 'checkbox': + case 'checkbox-group': return null default: { const _exhaustive: never = field @@ -138,6 +136,17 @@ function FieldInput({ } } +/** Selected `checkbox-group` values are stored as a semicolon-separated string. */ +const CHECKBOX_GROUP_DELIMITER = ';' + +function parseCheckboxGroup(value: string): string[] { + return value ? value.split(CHECKBOX_GROUP_DELIMITER).filter(Boolean) : [] +} + +function serializeCheckboxGroup(selected: string[]): string { + return selected.join(CHECKBOX_GROUP_DELIMITER) +} + function Field({ field, value, @@ -167,6 +176,39 @@ function Field({ ) } + if (field.type === 'checkbox-group') { + const selected = parseCheckboxGroup(value) + const toggle = (optValue: string, checked: boolean) => { + const next = checked ? [...selected, optValue] : selected.filter((v) => v !== optValue) + // Preserve option order so submitted values are deterministic. + const ordered = field.options.map((o) => o.value).filter((v) => next.includes(v)) + onChange(serializeCheckboxGroup(ordered)) + } + return ( +
+ +
+ {field.options.map((opt) => ( + + ))} +
+ {field.description && ( +

{field.description}

+ )} +
+ ) + } + return (
@@ -192,9 +234,10 @@ export default function MarketingForm({ formRef, card = true, className, + initialValues, }: MarketingFormProps) { const [values, setValues] = useState>(() => - Object.fromEntries(fields.map((f) => [f.name, ''])) + Object.fromEntries(fields.map((f) => [f.name, initialValues?.[f.name] ?? ''])) ) const [submitState, setSubmitState] = useState('idle') const [errorMessages, setErrorMessages] = useState([]) @@ -216,15 +259,33 @@ export default function MarketingForm({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + // Required checkboxes / checkbox-groups aren't covered by HTML5 validation; + // check them manually. + const validationErrors: string[] = [] + for (const f of visibleFields) { + if (!f.required) continue + if (f.type === 'checkbox' && values[f.name] !== 'true') { + validationErrors.push(`Please confirm: ${f.label.replace(/\*$/, '').trim()}`) + } else if ( + f.type === 'checkbox-group' && + parseCheckboxGroup(values[f.name] ?? '').length === 0 + ) { + validationErrors.push(`Please select at least one: ${f.label.replace(/\*$/, '').trim()}`) + } + } + if (validationErrors.length > 0) { + setSubmitState('error') + setErrorMessages(validationErrors) + return + } + // Required checkboxes aren't covered by HTML5 validation; check them manually. const uncheckedRequired = visibleFields.filter( (f) => f.type === 'checkbox' && f.required && !f.group && values[f.name] !== 'true' ) if (uncheckedRequired.length > 0) { setSubmitState('error') - setErrorMessages( - uncheckedRequired.map((f) => `Please confirm: ${f.label.replace(/\*$/, '').trim()}`) - ) + setErrorMessages(validationErrors) return } @@ -258,11 +319,19 @@ export default function MarketingForm({ if (process.env.NODE_ENV === 'development') { console.log('[marketing/form] No formRef configured — form values:', submittedValues) } + // Generic message: don't hint at *why* submission didn't happen (e.g. a + // missing formRef), since that's an internal config detail. + setSubmitState('error') + setErrorMessages(['Something went wrong. Please try again.']) return } // Block repeat submissions of the same email to the same form within this - // browser session. Render the success state instead of re-hitting the CRM. + // browser session, without re-hitting the CRM. `successRedirect` forms + // still redirect (the destination page already reads as a confirmation); + // others surface a distinguishable message rather than silently replaying + // the success screen, so a second attempt (e.g. after fixing a mistake) + // doesn't look like it worked when nothing was actually resubmitted. const emailValue = submittedValues['email'] ?? submittedValues['workEmail'] ?? @@ -277,7 +346,8 @@ export default function MarketingForm({ if (successRedirect) { window.location.href = successRedirect } else { - setSubmitState('success') + setSubmitState('error') + setErrorMessages(['You’ve already submitted this form.']) } return } @@ -326,12 +396,12 @@ export default function MarketingForm({ } // Group fields into rows: half-width fields pair up, full-width fields get their own row. - // Checkbox fields always take a full row regardless of their `half` flag. + // Checkbox + checkbox-group fields always take a full row regardless of their `half` flag. const rows: MarketingFormField[][] = [] let pendingHalf: MarketingFormField | null = null for (const field of visibleFields) { - const isHalf = field.half && field.type !== 'checkbox' + const isHalf = field.half && field.type !== 'checkbox' && field.type !== 'checkbox-group' if (isHalf) { if (pendingHalf) { rows.push([pendingHalf, field]) @@ -351,20 +421,41 @@ export default function MarketingForm({ rows.push([pendingHalf]) } + // Rendered first in both the form and success views so React keeps the same + // live region mounted across the swap — screen readers only announce changes + // to a region that already exists. + const statusRegion = ( +

+ {submitState === 'success' ? 'Thank you! We’ve received your submission.' : ''} +

+ ) + if (submitState === 'success') { return (
+ {statusRegion}
-

Thank you!

-

- {successMessage ?? "We've received your submission and will be in touch soon."} -

+

Thank you!

+
+

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {successMessage ?? "We've received your submission and will be in touch soon."} +
+
) @@ -372,6 +463,7 @@ export default function MarketingForm({ return (
+ {statusRegion} {(title || description) && (
{title && ( @@ -386,7 +478,7 @@ export default function MarketingForm({ onSubmit={handleSubmit} className={ card - ? 'border border-muted rounded-2xl p-6 sm:p-8 flex flex-col gap-6' + ? 'border bg-surface-75 rounded-2xl shadow-xl p-6 sm:p-8 flex flex-col gap-6' : 'flex flex-col gap-6' } > @@ -397,7 +489,7 @@ export default function MarketingForm({ */}