Files
supabase/apps/www/components/Forms/ApplyToSupaSquadForm.tsx
Gildas GarciaandIvan Vasilov 96d43099bb chore: refactor Button API so that it can be used a standard button (#46880)
## Problem

Our `<Button>` component breaks the default `button` contract by
redefining the `type` prop to set its variant (`primary`, `default`,
etc) instead of the button type (`submit`, `button`, etc).
This is confusing and forces to write more code when using it with
shadcn components that expect/inject the standard button props.

## Solution

- rename the `type` prop to `variant`
- rename the `htmlType` prop to `type`
- propagate the changes where necessary
- format code

## How to test

As this is just prop renaming, if it builds it's ok

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-06-16 23:59:58 +02:00

716 lines
23 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import {
SupaSquadApplication,
supaSquadApplicationSchema,
} from '~/data/open-source/contributing/supasquad.utils'
import { AlertCircle } from 'lucide-react'
import { FC, memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
TextArea,
} from 'ui'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
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 { CountrySelector } from '../Supasquad/CountrySelector'
interface FormItem {
type: 'text' | 'textarea'
label: string
placeholder: string
required: boolean
className?: string
component: typeof TextArea | typeof Input
}
interface Track {
heading: string
description: string
}
interface Props {
className?: string
title?: string
description?: string
}
const tracks: Track[] = [
{
heading: 'Advocate',
description: 'Help spread word on socials answer community questions',
},
{
heading: 'Helper',
description: 'Answer questions and improve docs across our platforms',
},
{
heading: 'Builder/Maintainer',
description: 'Contribute to client libraries, manage issues, fix bugs',
},
{
heading: 'Moderator',
description: 'Ensure our social spaces remain productive and helpful',
},
]
const productAreasOfInterest: string[] = [
'Auth',
'AI Builders',
'Branching',
'Client libraries',
'Database / Postgres',
'Dashboard',
'CLI',
'Edge Functions',
'Integrations',
'Realtime',
'Storage',
'Vectors / AI',
'Other',
]
const languagesSpoken: string[] = [
'English',
'Spanish',
'Portuguese',
'French',
'German',
'Italian',
'Russian',
'Arabic',
'Hindi',
'Mandarin Chinese',
'Japanese',
'Turkish',
'Indonesian',
'Thai',
'Vietnamese',
'Bengali',
'Urdu',
'Polish',
'Dutch',
'Other',
]
const FormContent = memo(function FormContent({
form,
errors,
onSubmit,
honeypot,
setHoneypot,
isMobile,
isSubmitted,
handleCancel,
isSubmitting,
}: {
form: ReturnType<typeof useForm<SupaSquadApplication>>
errors: { [key: string]: string }
onSubmit: (data: SupaSquadApplication) => Promise<void>
honeypot: string
setHoneypot: React.Dispatch<React.SetStateAction<string>>
isMobile: boolean
isSubmitted: boolean
handleCancel: () => void
isSubmitting: boolean
}) {
return (
<div className="flex flex-col">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 text-left"
name="application-form"
noValidate
>
<div className="flex flex-col md:flex-row gap-4 items-start text-left">
<FormField
control={form.control}
name="first_name"
render={({ field }) => (
<FormItem className="w-full md:flex-1">
<FormLabel className="text-foreground">First Name *</FormLabel>
<FormControl>
<Input placeholder="Your first name" autoComplete="given-name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="last_name"
render={({ field }) => (
<FormItem className="w-full md:flex-1">
<FormLabel className="text-foreground">Last Name *</FormLabel>
<FormControl>
<div className="relative ">
<Input placeholder="Your last name" autoComplete="family-name" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">Email Address *</FormLabel>
<FormControl>
<div className="relative">
<Input
type="email"
placeholder="Your personal or work email"
autoComplete="email"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<div className="space-y-8">
<h3 className="h3 text-foreground">Interests and skills</h3>
<FormField
control={form.control}
name="tracks"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">
What track would you like to be considered for? *
</FormLabel>
<FormDescription className="text-foreground-lighter">
See longer descriptions of the 4 options above
</FormDescription>
<FormControl>
<div className="relative mt-1">
<MultiSelector
onValuesChange={(values) => {
// Convert selected headings back to track objects
const selectedTracks = values.map(
(heading) => tracks.find((track) => track.heading === heading)!
)
field.onChange(selectedTracks)
}}
values={field.value.map((track) => track.heading)}
size="small"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select as many as you want"
badgeLimit="wrap"
showIcon={false}
deletableBadge
/>
<MultiSelectorContent>
<MultiSelectorList>
{tracks.map((item) => (
<MultiSelectorItem key={item.heading} value={item.heading}>
<div>
<div className="font-medium text-foreground">{item.heading}</div>
<div className="text-xs text-muted-foreground">
{item.description}
</div>
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="why_you_want_to_join"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className="text-foreground">
Why do you want to join the program? *
</FormLabel>
<FormDescription className="text-foreground-lighter">
What do you have to contribute? What would you like to get out of it?
</FormDescription>
<FormControl>
<div className="relative">
<TextArea
autoComplete="off"
rows={3}
className="bg-foreground/[.026]"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contributions"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className="text-foreground">
Share some of your recent contributions *
</FormLabel>
<FormDescription className="text-foreground-lighter">
<p>
Any relevant links to show your current engagement with the Supabase
community.
</p>
<p>
If you haven&apos;t contributed yet, spend some time engaging with the
community, then reapply once you&apos;ve built up a few contributions.
</p>
</FormDescription>
<FormControl>
<div className="relative">
<TextArea
autoComplete="off"
rows={3}
className="bg-foreground/[.026]"
placeholder="PR links, posts/replies on Discord/Reddit/X, Luma links of meetups
you have organized, etc."
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="areas_of_interest"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">Product Areas of Interest *</FormLabel>
<FormDescription className="text-foreground-lighter">
What specific areas would you like to help with? Leave blank if you&apos;re not
sure.
</FormDescription>
<FormControl>
<div className="relative mt-1">
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select as many as you want"
badgeLimit="wrap"
showIcon={false}
deletableBadge
/>
<MultiSelectorContent>
<MultiSelectorList>
{productAreasOfInterest.map((item) => (
<MultiSelectorItem key={item} value={item}>
{item}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="skills"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">
Skills (frameworks, tools, programming languages)
</FormLabel>
<FormDescription className="text-foreground-lighter">
Know Postgres really well? React? Expo? Python? Rust? Terraform? Add it here!
</FormDescription>
<FormControl>
<div className="relative mt-1">
<Input type="text" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
<div className="space-y-6">
<h3 className="h3 text-foreground">Location and Availability</h3>
<div className="flex flex-col md:flex-row gap-4 items-start text-left">
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem className="w-full md:flex-1">
<FormLabel className="text-foreground">Country *</FormLabel>
<FormControl>
<div className="relative mt-1">
<CountrySelector value={field.value || ''} onValueChange={field.onChange} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem className="w-full md:flex-1">
<FormLabel className="text-foreground">City *</FormLabel>
<FormControl>
<div className="relative mt-1">
<Input type="text" placeholder="City" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="monthly_commitment"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">Monthly Commitment</FormLabel>
<FormDescription className="text-foreground-lighter">
How many hours can you commit per month? If not sure, leave blank.
</FormDescription>
<FormControl>
<div className="relative mt-1">
<Input {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="languages_spoken"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">Languages spoken *</FormLabel>
<FormDescription className="text-foreground-lighter">
What languages do you speak?
</FormDescription>
<FormControl>
<div className="relative mt-1">
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select as many as you want"
badgeLimit="wrap"
showIcon={false}
deletableBadge
/>
<MultiSelectorContent>
<MultiSelectorList>
{languagesSpoken.map((item) => (
<MultiSelectorItem key={item} value={item}>
{item}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
<div className="space-y-6">
<h3 className="h4 text-foreground">Social Links</h3>
<div className="space-y-3">
<FormField
control={form.control}
name="discord"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">Discord</FormLabel>
<FormControl>
<div className="relative mt-1">
<Input type="text" placeholder="#username" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="github"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">GitHub</FormLabel>
<FormControl>
<div className="relative mt-1">
<Input type="text" placeholder="@yourusername" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="twitter"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">Twitter</FormLabel>
<FormControl>
<div className="relative mt-1">
<Input type="text" placeholder="@yourhandle" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{!isSubmitted && (
<div className="flex flex-row gap-3">
<Button
size="small"
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="border-border text-foreground hover:bg-muted flex-1"
>
Cancel
</Button>
<Button size="small" type="submit" disabled={isSubmitting} className="flex-1">
{isSubmitting ? <>Submitting...</> : <>Submit Application</>}
</Button>
</div>
)}
{/* Spam prevention */}
<input
type="text"
name="honeypot"
value={honeypot}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHoneypot(e.target.value)}
className="hidden"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
</form>
</Form>
{Object.values(errors).length > 0 && (
<Alert variant="destructive" className="mt-6">
<AlertCircle />
<AlertDescription>{Object.values(errors).join('\n')}</AlertDescription>
</Alert>
)}
</div>
)
})
const ApplyToSupaSquadForm: FC<Props> = ({
className,
title = 'Apply to join SupaSquad',
description = 'Join our community of passionate contributors and help shape the future of Supabase. Fill out the form below to apply.',
}) => {
const [honeypot, setHoneypot] = useState<string>('') // field to prevent spam
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
const [showConfirmation, setShowConfirmation] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const form = useForm<SupaSquadApplication>({
resolver: zodResolver(supaSquadApplicationSchema),
defaultValues: {
first_name: '',
last_name: '',
email: '',
tracks: [],
areas_of_interest: [],
contributions: '',
skills: '',
why_you_want_to_join: '',
city: '',
country: '',
monthly_commitment: '',
languages_spoken: [],
github: '',
twitter: '',
discord: '',
},
mode: 'onBlur',
reValidateMode: 'onBlur',
})
const handleCancel = () => {
form.reset()
setIsSubmitted(false)
}
const handleConfirmationClose = () => {
form.reset()
setShowConfirmation(false)
setIsSubmitted(false)
setErrors({})
}
const onSubmit = async (data: SupaSquadApplication) => {
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
}
setIsSubmitting(true)
setSuccess(null)
try {
const response = await fetch('/api-v2/submit-form-apply-to-supasquad', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (response.ok) {
setSuccess('Thank you for your submission!')
setShowConfirmation(true)
} else {
const errorData = await response.json()
setErrors({ general: `Submission failed: ${errorData.message}` })
}
} catch (error) {
setErrors({ general: 'An unexpected error occurred. Please try again.' })
} finally {
setIsSubmitting(false)
}
}
useEffect(() => {
setStartTime(Date.now())
}, [])
return (
<>
<div className={className}>
<div className="mb-6">
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
<p className="text-muted-foreground">{description}</p>
</div>
<Separator className="my-6" />
<FormContent
form={form}
errors={errors}
onSubmit={onSubmit}
honeypot={honeypot}
setHoneypot={setHoneypot}
isMobile={false}
isSubmitted={isSubmitted}
handleCancel={handleCancel}
isSubmitting={isSubmitting}
/>
</div>
{/* Confirmation AlertDialog Overlay */}
<AlertDialog open={showConfirmation} onOpenChange={() => {}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Application submitted</AlertDialogTitle>
<AlertDialogDescription>
Thank you for your submission! Please check your email for a confirmation link to
complete your application.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleConfirmationClose}>Got it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default ApplyToSupaSquadForm