improving apply workflow

This commit is contained in:
Alan Daniel committed 2026-01-22 14:53:10 -04:00
1 parent d34cc65e30
commit 01f3fddae0
11 files changed
+1472 -8

No files matched your search

@@ -0,0 +1,80 @@
import { createClient } from '@supabase/supabase-js'
import type { SlugCheckResponse } from '~/data/partners/partnerApplication.utils'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET,OPTIONS',
}
export async function OPTIONS() {
return new Response('ok', { headers: corsHeaders })
}
export async function GET(req: Request) {
const url = new URL(req.url)
const slug = url.searchParams.get('slug')
if (!slug) {
return new Response(JSON.stringify({ error: 'Slug parameter is required' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
// Validate slug format
if (!/^[a-z0-9-]+$/.test(slug)) {
return new Response(
JSON.stringify({ available: false, error: 'Invalid slug format' } as SlugCheckResponse & {
error: string
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
}
)
}
const supabaseUrl = process.env.NEXT_PUBLIC_MISC_USE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_MISC_USE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
return new Response(JSON.stringify({ error: 'Server misconfigured' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
const supabase = createClient(supabaseUrl, supabaseAnonKey)
try {
const { data, error } = await supabase
.from('partners')
.select('slug')
.eq('slug', slug)
.maybeSingle()
if (error) {
console.error('Error checking slug:', error)
return new Response(JSON.stringify({ error: 'Error checking slug availability' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
const response: SlugCheckResponse = {
available: data === null,
}
return new Response(JSON.stringify(response), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
} catch (error) {
console.error('Error checking slug:', error)
return new Response(JSON.stringify({ error: 'Error checking slug availability' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
}
@@ -0,0 +1,110 @@
import { createClient } from '@supabase/supabase-js'
import type { LogoUploadResponse } from '~/data/partners/partnerApplication.utils'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST,OPTIONS',
}
export async function OPTIONS() {
return new Response('ok', { headers: corsHeaders })
}
interface UploadUrlRequest {
slug: string
fileExtension: string
}
export async function POST(req: Request) {
const supabaseUrl = process.env.NEXT_PUBLIC_MISC_USE_URL
const supabaseServiceKey = process.env.MISC_USE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return new Response(
JSON.stringify({ error: 'Server misconfigured: missing Supabase credentials' }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
let body: UploadUrlRequest
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
const { slug, fileExtension } = body
if (!slug || !fileExtension) {
return new Response(JSON.stringify({ error: 'slug and fileExtension are required' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
// Validate slug format
if (!/^[a-z0-9-]+$/.test(slug)) {
return new Response(JSON.stringify({ error: 'Invalid slug format' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
// Validate file extension
const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']
const normalizedExtension = fileExtension.toLowerCase().replace('.', '')
if (!allowedExtensions.includes(normalizedExtension)) {
return new Response(
JSON.stringify({ error: `Invalid file extension. Allowed: ${allowedExtensions.join(', ')}` }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
}
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
try {
const path = `integrations/${slug}/logo.${normalizedExtension}`
const { data, error } = await supabase.storage
.from('images')
.createSignedUploadUrl(path, { upsert: true })
if (error) {
console.error('Error creating signed URL:', error)
return new Response(JSON.stringify({ error: 'Error creating upload URL' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/images/${path}`
const response: LogoUploadResponse = {
signedUrl: data.signedUrl,
path: data.path,
publicUrl,
token: data.token,
}
return new Response(JSON.stringify(response), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
} catch (error) {
console.error('Error creating signed URL:', error)
return new Response(JSON.stringify({ error: 'Error creating upload URL' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
}
@@ -0,0 +1,270 @@
import { after } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import {
partnerApplicationSchema,
type PartnerApplication,
type PartnerApplicationResponse,
} from '~/data/partners/partnerApplication.utils'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST,OPTIONS',
}
export async function OPTIONS() {
return new Response('ok', { headers: corsHeaders })
}
async function verifyHCaptcha(token: string): Promise<boolean> {
const secret = process.env.HCAPTCHA_SECRET_KEY
if (!secret) {
console.error('HCAPTCHA_SECRET_KEY not configured')
return false
}
try {
const response = await fetch('https://api.hcaptcha.com/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
secret,
response: token,
}),
})
const data = await response.json()
return data.success === true
} catch (error) {
console.error('hCaptcha verification error:', error)
return false
}
}
async function triggerHubSpotWorkflow(
contactData: PartnerApplication['contact'],
partnerId: number
) {
console.log('HUBSPOT WORKFLOW (TO BE CREATED)', { email: contactData.email, partnerId })
}
export async function POST(req: Request) {
const supabaseUrl = process.env.NEXT_PUBLIC_MISC_USE_URL
const supabaseServiceKey = process.env.MISC_USE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return new Response(
JSON.stringify({
success: false,
message: 'Server misconfigured: missing Supabase credentials',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
let body: unknown
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ success: false, message: 'Invalid JSON' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
// Validate request body
const parsed = partnerApplicationSchema.safeParse(body)
if (!parsed.success) {
return new Response(
JSON.stringify({
success: false,
message: 'Validation failed',
errors: parsed.error.flatten(),
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 422,
}
)
}
const data = parsed.data
// Verify hCaptcha token
const captchaValid = await verifyHCaptcha(data.captchaToken)
if (!captchaValid) {
return new Response(
JSON.stringify({ success: false, message: 'Captcha verification failed' }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
}
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
try {
// Check if slug is already taken
const { data: existingPartner } = await supabase
.from('partners')
.select('slug')
.eq('slug', data.partner.slug)
.maybeSingle()
if (existingPartner) {
return new Response(
JSON.stringify({
success: false,
message: 'This slug is already taken. Please choose a different one.',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
}
)
}
// Check if contact already exists by email
const { data: existingContact } = await supabase
.from('partner_contacts')
.select('id')
.eq('email', data.contact.email)
.maybeSingle()
let contactId: number
if (existingContact) {
// Update existing contact
const { error: updateError } = await supabase
.from('partner_contacts')
.update({
first: data.contact.first,
last: data.contact.last,
company: data.contact.company,
country: data.contact.country,
website: data.contact.website,
phone: data.contact.phone || null,
title: data.contact.title || null,
size: data.contact.size || null,
details: data.contact.details || null,
type: 'technology', // Set type for integration partners
})
.eq('id', existingContact.id)
if (updateError) {
console.error('Error updating contact:', updateError)
return new Response(
JSON.stringify({ success: false, message: 'Error updating contact information' }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
contactId = existingContact.id
} else {
// Insert new contact
const { data: newContact, error: insertError } = await supabase
.from('partner_contacts')
.insert({
first: data.contact.first,
last: data.contact.last,
email: data.contact.email,
company: data.contact.company,
country: data.contact.country,
website: data.contact.website,
phone: data.contact.phone || null,
title: data.contact.title || null,
size: data.contact.size || null,
details: data.contact.details || null,
type: 'technology', // Set type for integration partners
})
.select('id')
.single()
if (insertError || !newContact) {
console.error('Error inserting contact:', insertError)
return new Response(
JSON.stringify({ success: false, message: 'Error saving contact information' }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
contactId = newContact.id
}
// Insert partner with approved = false
const { data: newPartner, error: partnerError } = await supabase
.from('partners')
.insert({
slug: data.partner.slug,
type: 'technology',
category: data.partner.category,
developer: data.partner.developer,
title: data.partner.title,
description: data.partner.description,
logo: data.partner.logo,
overview: data.partner.overview,
website: data.partner.website,
docs: data.partner.docs || null,
video: data.partner.video || null,
call_to_action_link: data.partner.call_to_action_link || null,
contact: contactId,
approved: false, // Always set to false for new applications
})
.select('id')
.single()
if (partnerError || !newPartner) {
console.error('Error inserting partner:', partnerError)
return new Response(
JSON.stringify({ success: false, message: 'Error saving partner information' }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
// Trigger HubSpot workflow after response is sent
after(() => {
triggerHubSpotWorkflow(data.contact, newPartner.id)
})
const response: PartnerApplicationResponse = {
success: true,
message:
'Your partner application has been submitted successfully! We will review it and get back to you soon.',
partnerId: newPartner.id,
contactId,
}
return new Response(JSON.stringify(response), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 201,
})
} catch (error) {
console.error('Error submitting partner application:', error)
return new Response(
JSON.stringify({
success: false,
message: 'An unexpected error occurred. Please try again.',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
}
@@ -7,9 +7,7 @@ export default function BecomeAPartner() {
<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>
<Link href="/partners/apply">Become a partner</Link>
</Button>
</div>
</div>
@@ -0,0 +1,870 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, CheckCircle2, Loader2, Upload, X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
TextArea_Shadcn_,
} from 'ui'
import { Alert, AlertDescription } from 'ui/src/components/shadcn/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from 'ui/src/components/shadcn/ui/alert-dialog'
import {
PARTNER_CATEGORIES,
partnerApplicationSchema,
type PartnerApplication,
type LogoUploadResponse,
} from '~/data/partners/partnerApplication.utils'
import { CountrySelector } from '../Supasquad/CountrySelector'
import { useDebounce } from 'use-debounce'
import supabase from '~/lib/supabaseMisc'
interface Props {
className?: string
}
const PartnerApplicationForm: FC<Props> = ({ className }) => {
const { resolvedTheme } = useTheme()
const [honeypot, setHoneypot] = useState<string>('')
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
// Logo upload state
const [logoFile, setLogoFile] = useState<File | null>(null)
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
// Slug validation state
const [isCheckingSlug, setIsCheckingSlug] = useState(false)
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null)
const captchaRef = useRef<HCaptcha | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const form = useForm<PartnerApplication>({
resolver: zodResolver(partnerApplicationSchema),
defaultValues: {
contact: {
first: '',
last: '',
email: '',
company: '',
country: '',
website: '',
phone: '',
title: '',
details: '',
},
partner: {
slug: '',
title: '',
description: '',
category: '',
developer: '',
logo: '',
overview: '',
website: '',
docs: '',
video: '',
call_to_action_link: '',
},
captchaToken: '',
},
mode: 'onBlur',
reValidateMode: 'onBlur',
})
const slugValue = form.watch('partner.slug')
const [debouncedSlug] = useDebounce(slugValue, 500)
// Check slug availability
useEffect(() => {
const checkSlug = async () => {
if (!debouncedSlug || debouncedSlug.length < 2) {
setSlugAvailable(null)
return
}
if (!/^[a-z0-9-]+$/.test(debouncedSlug)) {
setSlugAvailable(false)
return
}
setIsCheckingSlug(true)
try {
const response = await fetch(
`/api-v2/check-partner-slug?slug=${encodeURIComponent(debouncedSlug)}`
)
const data = await response.json()
setSlugAvailable(data.available)
} catch {
setSlugAvailable(null)
} finally {
setIsCheckingSlug(false)
}
}
checkSlug()
}, [debouncedSlug])
const handleCaptchaVerify = (token: string) => {
form.setValue('captchaToken', token)
}
const handleLogoSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
setErrors({ logo: 'Please upload a valid image file (JPG, PNG, GIF, WebP, or SVG)' })
return
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setErrors({ logo: 'Logo file must be less than 5MB' })
return
}
setLogoFile(file)
setLogoPreview(URL.createObjectURL(file))
setErrors({})
}, [])
const handleRemoveLogo = () => {
setLogoFile(null)
setLogoPreview(null)
form.setValue('partner.logo', '')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const uploadLogo = async (slug: string): Promise<string | null> => {
if (!logoFile) return null
setIsUploadingLogo(true)
try {
// Get file extension
const extension = logoFile.name.split('.').pop()?.toLowerCase() || 'png'
// Get signed upload URL
const urlResponse = await fetch('/api-v2/partner-logo-upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, fileExtension: extension }),
})
if (!urlResponse.ok) {
throw new Error('Failed to get upload URL')
}
const uploadData: LogoUploadResponse = await urlResponse.json()
// Upload the file using the signed URL
const uploadResponse = await supabase.storage
.from('images')
.uploadToSignedUrl(uploadData.path, uploadData.token, logoFile)
if (uploadResponse.error) {
throw uploadResponse.error
}
return uploadData.publicUrl
} catch (error) {
console.error('Logo upload error:', error)
throw error
} finally {
setIsUploadingLogo(false)
}
}
const onSubmit = async (data: PartnerApplication) => {
const currentTime = Date.now()
const timeElapsed = (currentTime - startTime) / 1000
// Spam prevention: Reject form if submitted too quickly (less than 3 seconds)
if (timeElapsed < 3) {
setErrors({ general: 'Submission too fast. Please fill the form correctly.' })
return
}
// Check honeypot
if (honeypot !== '') {
setErrors({ general: 'Spam detected.' })
return
}
// Check slug availability one more time
if (slugAvailable === false) {
setErrors({ general: 'The slug you chose is not available. Please choose a different one.' })
return
}
// Check if logo file is selected
if (!logoFile) {
setErrors({ logo: 'Please upload a logo' })
return
}
setIsSubmitting(true)
setErrors({})
try {
// Upload logo if selected
let logoUrl = data.partner.logo
if (logoFile) {
const uploadedUrl = await uploadLogo(data.partner.slug)
if (uploadedUrl) {
logoUrl = uploadedUrl
} else {
throw new Error('Failed to upload logo')
}
}
// Submit the application
const response = await fetch('/api-v2/submit-partner-application', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...data,
partner: {
...data.partner,
logo: logoUrl,
},
}),
})
const result = await response.json()
if (response.ok && result.success) {
setShowConfirmation(true)
} else {
setErrors({ general: result.message || 'Submission failed. Please try again.' })
// Reset captcha on error
captchaRef.current?.resetCaptcha()
form.setValue('captchaToken', '')
}
} catch (error) {
console.error('Submission error:', error)
setErrors({ general: 'An unexpected error occurred. Please try again.' })
captchaRef.current?.resetCaptcha()
form.setValue('captchaToken', '')
} finally {
setIsSubmitting(false)
}
}
const handleConfirmationClose = () => {
form.reset()
setShowConfirmation(false)
setErrors({})
setLogoFile(null)
setLogoPreview(null)
setSlugAvailable(null)
captchaRef.current?.resetCaptcha()
}
useEffect(() => {
setStartTime(Date.now())
}, [])
return (
<>
<div className={className}>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8" noValidate>
{/* Section 1: Contact Information */}
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground">Contact Information</h3>
<p className="text-sm text-foreground-lighter mt-1">
Tell us about yourself and your company
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField_Shadcn_
control={form.control}
name="contact.first"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
First Name *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="John" autoComplete="given-name" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="contact.last"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Last Name *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="Doe" autoComplete="family-name" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<FormField_Shadcn_
control={form.control}
name="contact.email"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Email Address *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
type="email"
placeholder="john@company.com"
autoComplete="email"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField_Shadcn_
control={form.control}
name="contact.company"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Company Name *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
placeholder="Acme Inc"
autoComplete="organization"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="contact.title"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Job Title</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
placeholder="CTO"
autoComplete="organization-title"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField_Shadcn_
control={form.control}
name="contact.country"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Country *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<CountrySelector value={field.value} onValueChange={field.onChange} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="contact.phone"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Phone</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
type="tel"
placeholder="+1 (555) 123-4567"
autoComplete="tel"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<FormField_Shadcn_
control={form.control}
name="contact.website"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Company Website *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
type="url"
placeholder="https://company.com"
autoComplete="url"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="contact.details"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Additional Details
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
Anything else you&apos;d like us to know about your company
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<TextArea_Shadcn_ rows={3} className="bg-foreground/[.026]" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<Separator />
{/* Section 2: Integration Information */}
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground">Integration Information</h3>
<p className="text-sm text-foreground-lighter mt-1">
Tell us about your integration with Supabase
</p>
</div>
<FormField_Shadcn_
control={form.control}
name="partner.slug"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">URL Slug *</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
This will be used in the URL: /partners/integrations/your-slug
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<div className="relative">
<Input_Shadcn_
placeholder="my-integration"
{...field}
onChange={(e) => {
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-')
field.onChange(value)
}}
/>
{isCheckingSlug && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2
className="h-4 w-4 text-foreground-lighter"
style={{ animation: 'spin 1s linear infinite' }}
/>
</div>
)}
{!isCheckingSlug && slugAvailable === true && slugValue.length >= 2 && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<CheckCircle2 className="h-4 w-4 text-brand" />
</div>
)}
{!isCheckingSlug && slugAvailable === false && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<X className="h-4 w-4 text-destructive" />
</div>
)}
</div>
</FormControl_Shadcn_>
{slugAvailable === false && (
<p className="text-sm text-destructive mt-1">This slug is already taken</p>
)}
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField_Shadcn_
control={form.control}
name="partner.title"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Integration Title *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="My Integration" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="partner.developer"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Developer/Company Name *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="Acme Inc" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<FormField_Shadcn_
control={form.control}
name="partner.category"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Category *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select a category" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{PARTNER_CATEGORIES.map((category) => (
<SelectItem_Shadcn_ key={category} value={category}>
{category}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="partner.description"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Short Description *
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
A brief description of your integration (max 500 characters)
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<TextArea_Shadcn_
rows={2}
className="bg-foreground/[.026]"
maxLength={500}
{...field}
/>
</FormControl_Shadcn_>
<p className="text-xs text-foreground-lighter mt-1">
{field.value?.length || 0}/500
</p>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="partner.overview"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Full Overview *
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
A detailed overview of your integration. Markdown is supported.
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<TextArea_Shadcn_ rows={6} className="bg-foreground/[.026]" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField_Shadcn_
control={form.control}
name="partner.website"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Integration Website *
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
type="url"
placeholder="https://integration.com"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="partner.docs"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Documentation URL
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
type="url"
placeholder="https://docs.integration.com"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField_Shadcn_
control={form.control}
name="partner.video"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Video URL</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
YouTube video ID or URL
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="dQw4w9WgXcQ" {...field} />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="partner.call_to_action_link"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Call to Action Link
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
Link for the &quot;Get Started&quot; button
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
type="url"
placeholder="https://integration.com/signup"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
</div>
<Separator />
{/* Section 3: Logo Upload */}
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground">Logo</h3>
<p className="text-sm text-foreground-lighter mt-1">
Upload your integration logo (JPG, PNG, GIF, WebP, or SVG, max 5MB)
</p>
</div>
<FormField_Shadcn_
control={form.control}
name="partner.logo"
render={() => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
onChange={handleLogoSelect}
className="hidden"
id="logo-upload"
/>
{logoPreview ? (
<div className="relative w-32 h-32 border border-border rounded-lg overflow-hidden">
<img
src={logoPreview}
alt="Logo preview"
className="w-full h-full object-contain bg-background"
/>
<button
type="button"
onClick={handleRemoveLogo}
className="absolute top-1 right-1 p-1 bg-background/80 rounded-full hover:bg-background"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<label
htmlFor="logo-upload"
className="flex flex-col items-center justify-center w-32 h-32 border-2 border-dashed border-border rounded-lg cursor-pointer hover:border-foreground-muted transition-colors"
>
<Upload className="h-8 w-8 text-foreground-lighter mb-2" />
<span className="text-sm text-foreground-lighter">Upload logo</span>
</label>
)}
</div>
</FormControl_Shadcn_>
{errors.logo && <p className="text-sm text-destructive mt-1">{errors.logo}</p>}
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<Separator />
{/* Captcha */}
<div className="space-y-4">
<FormField_Shadcn_
control={form.control}
name="captchaToken"
render={() => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<HCaptcha
ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || ''}
onVerify={handleCaptchaVerify}
theme={resolvedTheme === 'dark' ? 'dark' : 'light'}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
{/* Error display */}
{Object.values(errors).length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{Object.values(errors).join('\n')}</AlertDescription>
</Alert>
)}
{/* Submit button */}
<Button
htmlType="submit"
size="medium"
disabled={isSubmitting || isUploadingLogo || slugAvailable === false}
className="w-full"
loading={isSubmitting || isUploadingLogo}
>
{isSubmitting || isUploadingLogo
? isUploadingLogo
? 'Uploading logo...'
: 'Submitting...'
: 'Submit Application'}
</Button>
{/* Spam prevention */}
<input
type="text"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
className="hidden"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
</form>
</Form_Shadcn_>
</div>
{/* Confirmation AlertDialog */}
<AlertDialog open={showConfirmation} onOpenChange={() => {}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Application Submitted</AlertDialogTitle>
<AlertDialogDescription>
Thank you for your partner application! We will review it and get back to you soon.
Your integration will appear on the marketplace once approved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleConfirmationClose}>Got it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default PartnerApplicationForm
+1 -1
View File
@@ -40,7 +40,7 @@ export default {
),
cta: {
label: 'Become a Partner',
link: 'https://forms.supabase.com/partner',
link: '/partners/apply',
},
},
oAuthApp: {
@@ -0,0 +1,96 @@
import { z } from 'zod'
// Partner categories available for selection
export const PARTNER_CATEGORIES = [
'Analytics',
'Auth',
'Automation',
'Caching',
'CMS',
'Data Platform',
'Database',
'DevTools',
'ETL',
'IDE',
'Infra Automation',
'Low-Code',
'Messaging',
'Monitoring',
'Observability',
'Orchestration',
'ORM',
'Payment Processing',
'Replication',
'Search',
'Serverless',
'Other',
] as const
// partner_contacts fields
export const partnerContactSchema = z.object({
first: z.string().min(1, 'First name is required'),
last: z.string().min(1, 'Last name is required'),
email: z.string().email('Please enter a valid email address'),
company: z.string().min(1, 'Company name is required'),
country: z.string().min(1, 'Country is required'),
website: z.string().url('Please enter a valid URL'),
phone: z.string().optional(),
title: z.string().optional(),
size: z.number().int().positive().optional(),
details: z.string().optional(),
})
// partners fields
export const partnerSchema = z.object({
slug: z
.string()
.min(1, 'Slug is required')
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens')
.max(50, 'Slug must be 50 characters or less'),
title: z.string().min(1, 'Integration title is required').max(100),
description: z
.string()
.min(1, 'Description is required')
.max(500, 'Description must be 500 characters or less'),
category: z.string().min(1, 'Category is required'),
developer: z.string().min(1, 'Developer/Company name is required'),
logo: z.string().optional(), // URL is generated after file upload
overview: z.string().min(1, 'Overview is required'),
website: z.string().url('Please enter a valid website URL'),
docs: z.string().url('Please enter a valid docs URL').optional().or(z.literal('')),
video: z.string().optional(),
call_to_action_link: z.string().url().optional().or(z.literal('')),
})
// Combined schema for the full application form
export const partnerApplicationSchema = z.object({
// Contact information
contact: partnerContactSchema,
// Integration information
partner: partnerSchema,
// Captcha token
captchaToken: z.string().min(1, 'Please complete the captcha verification'),
})
export type PartnerContact = z.infer<typeof partnerContactSchema>
export type Partner = z.infer<typeof partnerSchema>
export type PartnerApplication = z.infer<typeof partnerApplicationSchema>
// API response types
export interface PartnerApplicationResponse {
success: boolean
message: string
partnerId?: number
contactId?: number
}
export interface SlugCheckResponse {
available: boolean
}
export interface LogoUploadResponse {
signedUrl: string
path: string
publicUrl: string
token: string
}
+37
View File
@@ -0,0 +1,37 @@
import { NextSeo } from 'next-seo'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import PartnerApplicationForm from '~/components/Partners/PartnerApplicationForm'
const meta_title = 'Become a Partner'
const meta_description =
'Apply to become a Supabase integration partner and list your integration in our marketplace.'
export default function PartnerApplyPage() {
return (
<>
<NextSeo
title={meta_title}
description={meta_description}
openGraph={{
title: meta_title,
description: meta_description,
url: 'https://supabase.com/partners/apply',
}}
/>
<DefaultLayout className="bg-alternative">
<SectionContainer className="py-16 md:py-24">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-12">
<h1 className="h1 mb-4">Become a Partner</h1>
<p className="text-foreground-lighter text-lg">
Apply to list your integration in the Supabase marketplace and grow your business.
</p>
</div>
<PartnerApplicationForm />
</div>
</SectionContainer>
</DefaultLayout>
</>
)
}
+1 -1
View File
@@ -207,7 +207,7 @@ const Partners = () => {
</h2>
<div className="w-full mt-4 flex items-center justify-center text-center gap-4">
<Button asChild size="medium">
<Link href="https://forms.supabase.com/partner" tabIndex={-1}>
<Link href="/partners/apply" tabIndex={-1}>
Become a Partner
</Link>
</Button>
@@ -149,10 +149,10 @@ function IntegrationPartnersPage(props: Props) {
<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`}
href="/partners/apply"
title="Become a partner"
color="brand"
description="Fill out a quick 30 second form to apply to become a partner"
description="Fill out a quick form to apply to become a partner"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
+4 -1
View File
@@ -155,6 +155,7 @@
"NEXT_PUBLIC_UI_LIBRARY_URL",
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"MISC_USE_SERVICE_ROLE_KEY",
"NEXT_PUBLIC_VERCEL_ENV",
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
"NEXT_PUBLIC_API_URL",
@@ -208,7 +209,9 @@
"NEXT_PUBLIC_SUPASQUAD_APPLICATION_FORM_URL",
"CUSTOMERIO_SITE_ID",
"CUSTOMERIO_API_KEY",
"CUSTOMERIO_APP_API_KEY"
"CUSTOMERIO_APP_API_KEY",
"HUBSPOT_API_KEY",
"HUBSPOT_PARTNER_WORKFLOW_ID"
],
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
},