mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 04:45:08 +03:00
improving apply workflow
This commit is contained in:
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'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 "Get Started" 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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/**"]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user