diff --git a/apps/www/app/api-v2/check-partner-slug/route.tsx b/apps/www/app/api-v2/check-partner-slug/route.tsx new file mode 100644 index 00000000000..33270a1a65f --- /dev/null +++ b/apps/www/app/api-v2/check-partner-slug/route.tsx @@ -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, + }) + } +} diff --git a/apps/www/app/api-v2/partner-logo-upload-url/route.tsx b/apps/www/app/api-v2/partner-logo-upload-url/route.tsx new file mode 100644 index 00000000000..e6297cbc299 --- /dev/null +++ b/apps/www/app/api-v2/partner-logo-upload-url/route.tsx @@ -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, + }) + } +} diff --git a/apps/www/app/api-v2/submit-partner-application/route.tsx b/apps/www/app/api-v2/submit-partner-application/route.tsx new file mode 100644 index 00000000000..98765864581 --- /dev/null +++ b/apps/www/app/api-v2/submit-partner-application/route.tsx @@ -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 { + 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, + } + ) + } +} diff --git a/apps/www/components/Partners/BecomeAPartner.tsx b/apps/www/components/Partners/BecomeAPartner.tsx index 7dca93382c3..210c0532597 100644 --- a/apps/www/components/Partners/BecomeAPartner.tsx +++ b/apps/www/components/Partners/BecomeAPartner.tsx @@ -7,9 +7,7 @@ export default function BecomeAPartner() {

Ready to work together?

diff --git a/apps/www/components/Partners/PartnerApplicationForm.tsx b/apps/www/components/Partners/PartnerApplicationForm.tsx new file mode 100644 index 00000000000..b51ff43f909 --- /dev/null +++ b/apps/www/components/Partners/PartnerApplicationForm.tsx @@ -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 = ({ className }) => { + const { resolvedTheme } = useTheme() + const [honeypot, setHoneypot] = useState('') + const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [showConfirmation, setShowConfirmation] = useState(false) + const [startTime, setStartTime] = useState(0) + + // Logo upload state + const [logoFile, setLogoFile] = useState(null) + const [logoPreview, setLogoPreview] = useState(null) + const [isUploadingLogo, setIsUploadingLogo] = useState(false) + + // Slug validation state + const [isCheckingSlug, setIsCheckingSlug] = useState(false) + const [slugAvailable, setSlugAvailable] = useState(null) + + const captchaRef = useRef(null) + const fileInputRef = useRef(null) + + const form = useForm({ + 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) => { + 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 => { + 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 ( + <> +
+ +
+ {/* Section 1: Contact Information */} +
+
+

Contact Information

+

+ Tell us about yourself and your company +

+
+ +
+ ( + + + First Name * + + + + + + + )} + /> + + ( + + Last Name * + + + + + + )} + /> +
+ + ( + + + Email Address * + + + + + + + )} + /> + +
+ ( + + + Company Name * + + + + + + + )} + /> + + ( + + Job Title + + + + + + )} + /> +
+ +
+ ( + + Country * + + + + + + )} + /> + + ( + + Phone + + + + + + )} + /> +
+ + ( + + + Company Website * + + + + + + + )} + /> + + ( + + + Additional Details + + + Anything else you'd like us to know about your company + + + + + + + )} + /> +
+ + + + {/* Section 2: Integration Information */} +
+
+

Integration Information

+

+ Tell us about your integration with Supabase +

+
+ + ( + + URL Slug * + + This will be used in the URL: /partners/integrations/your-slug + + +
+ { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') + field.onChange(value) + }} + /> + {isCheckingSlug && ( +
+ +
+ )} + {!isCheckingSlug && slugAvailable === true && slugValue.length >= 2 && ( +
+ +
+ )} + {!isCheckingSlug && slugAvailable === false && ( +
+ +
+ )} +
+
+ {slugAvailable === false && ( +

This slug is already taken

+ )} + +
+ )} + /> + +
+ ( + + + Integration Title * + + + + + + + )} + /> + + ( + + + Developer/Company Name * + + + + + + + )} + /> +
+ + ( + + Category * + + + + + + + {PARTNER_CATEGORIES.map((category) => ( + + {category} + + ))} + + + + + + )} + /> + + ( + + + Short Description * + + + A brief description of your integration (max 500 characters) + + + + +

+ {field.value?.length || 0}/500 +

+ +
+ )} + /> + + ( + + + Full Overview * + + + A detailed overview of your integration. Markdown is supported. + + + + + + + )} + /> + +
+ ( + + + Integration Website * + + + + + + + )} + /> + + ( + + + Documentation URL + + + + + + + )} + /> +
+ +
+ ( + + Video URL + + YouTube video ID or URL + + + + + + + )} + /> + + ( + + + Call to Action Link + + + Link for the "Get Started" button + + + + + + + )} + /> +
+
+ + + + {/* Section 3: Logo Upload */} +
+
+

Logo

+

+ Upload your integration logo (JPG, PNG, GIF, WebP, or SVG, max 5MB) +

+
+ + ( + + +
+ + + {logoPreview ? ( +
+ Logo preview + +
+ ) : ( + + )} +
+
+ {errors.logo &&

{errors.logo}

} + +
+ )} + /> +
+ + + + {/* Captcha */} +
+ ( + + + + + + + )} + /> +
+ + {/* Error display */} + {Object.values(errors).length > 0 && ( + + + {Object.values(errors).join('\n')} + + )} + + {/* Submit button */} + + + {/* Spam prevention */} + setHoneypot(e.target.value)} + className="hidden" + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + /> + +
+
+ + {/* Confirmation AlertDialog */} + {}}> + + + Application Submitted + + 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. + + + + Got it + + + + + ) +} + +export default PartnerApplicationForm diff --git a/apps/www/data/partners/index.tsx b/apps/www/data/partners/index.tsx index 449a3ec9e6c..8b07a88b67f 100644 --- a/apps/www/data/partners/index.tsx +++ b/apps/www/data/partners/index.tsx @@ -40,7 +40,7 @@ export default { ), cta: { label: 'Become a Partner', - link: 'https://forms.supabase.com/partner', + link: '/partners/apply', }, }, oAuthApp: { diff --git a/apps/www/data/partners/partnerApplication.utils.ts b/apps/www/data/partners/partnerApplication.utils.ts new file mode 100644 index 00000000000..83c8ae65cf9 --- /dev/null +++ b/apps/www/data/partners/partnerApplication.utils.ts @@ -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 +export type Partner = z.infer +export type PartnerApplication = z.infer + +// 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 +} diff --git a/apps/www/pages/partners/apply.tsx b/apps/www/pages/partners/apply.tsx new file mode 100644 index 00000000000..0e4bc1273d1 --- /dev/null +++ b/apps/www/pages/partners/apply.tsx @@ -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 ( + <> + + + +
+
+

Become a Partner

+

+ Apply to list your integration in the Supabase marketplace and grow your business. +

+
+ +
+
+
+ + ) +} diff --git a/apps/www/pages/partners/index.tsx b/apps/www/pages/partners/index.tsx index 10e928bf3e3..ed826b1f4ac 100644 --- a/apps/www/pages/partners/index.tsx +++ b/apps/www/pages/partners/index.tsx @@ -207,7 +207,7 @@ const Partners = () => {
diff --git a/apps/www/pages/partners/integrations/index.tsx b/apps/www/pages/partners/integrations/index.tsx index 1cc9b5c59be..87972f68ce2 100644 --- a/apps/www/pages/partners/integrations/index.tsx +++ b/apps/www/pages/partners/integrations/index.tsx @@ -149,10 +149,10 @@ function IntegrationPartnersPage(props: Props) {
Explore more