New supasquad page (#38441)

* Create initial draft

* Update application url

* Copy changes

* Update quote samples

* Fix typo

* Fix prop value

* Fix hydration errors

* Fix hydration errors, 2

* Fix hydration errors, 3

* Solve icons hydration issue

* Remove console log

* hide quotes

* Nudges

* Add form component and integration to notion (WIP)

* Fix some fields mapping

* Nudges and add form inline

* Some fixes

* Refactor notion lib

* Integrate customerio

* Remove logs

* Apply some minor fixes

* Add country selector and split location fields

* Mark required fields

* Add sentry error capturing

* Add sentry dsn routing to edge config

* Fix with a different sentry client

* Remove custom transport on default sentry

* Fix prettier issues

* Use node client on sentry

* Make monthly commitment text

* Minor improvements

* Use text monthly commitment in notion

* Remove country flag on select item for typing search

* Update apps/www/data/open-source/contributing/supasquad.tsx

* Restore form reset

* Fix missing env vars

---------

Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
Tomás PozoandTerry Sutton authored and GitHub committed 2025-09-05 16:57:28 -02:30
1 parent e5a26a9202
commit 3cc4e215d5
18 files changed
+2422 -113

No files matched your search

@@ -0,0 +1,294 @@
import * as Sentry from '@sentry/nextjs'
import z from 'zod'
import { CustomerioAppClient, CustomerioTrackClient } from '~/lib/customerio'
import { insertPageInDatabase } from '~/lib/notion'
// Using a separate Sentry client for community following this guide:
// https://docs.sentry.io/platforms/javascript/best-practices/multiple-sentry-instances/
const integrations = Sentry.getDefaultIntegrations({}).filter((defaultIntegration) => {
return !['BrowserApiErrors', 'Breadcrumbs', 'GlobalHandlers'].includes(defaultIntegration.name)
})
const sentryCommunityClient = new Sentry.NodeClient({
dsn: process.env.SENTRY_DSN_COMMUNITY,
transport: Sentry.makeNodeTransport,
stackParser: Sentry.defaultStackParser,
integrations: [...integrations],
})
const sentryCommunity = new Sentry.Scope()
sentryCommunity.setClient(sentryCommunityClient)
const captureSentryCommunityException = (error: any) => {
if (process.env.SENTRY_DSN_COMMUNITY) {
sentryCommunity.captureException(error)
}
}
const NOTION_API_KEY = process.env.NOTION_SUPASQUAD_API_KEY
const NOTION_DB_ID = process.env.NOTION_SUPASQUAD_APPLICATIONS_DB_ID
const applicationSchema = z.object({
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required'),
email: z.string().email('Please enter a valid email address'),
tracks: z
.array(
z.object({
heading: z.string(),
description: z.string(),
})
)
.min(1, 'Select at least 1 track'),
areas_of_interest: z.array(z.string()).min(1, 'Select at least 1 area of interest'),
why_you_want_to_join: z.string().min(1, 'This is required'),
monthly_commitment: z.string().optional(),
languages_spoken: z.array(z.string()).min(1, 'Select at least 1 language'),
skills: z.string().optional(),
city: z.string().min(1, 'Specify your city'),
country: z.string().min(1, 'Specify your country'),
github: z.string().optional(),
twitter: z.string().optional(),
})
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 })
}
function truncateRichText(s: string, max = 1900) {
if (!s) return ''
return s.length > max ? s.slice(0, max) + '…' : s
}
function asMultiSelect(values: string[]) {
return values.map((v) => ({ name: v }))
}
function normalizeTrack(t: { heading: string; description: string } | string) {
// Handle both old string format and new object format
const trackName = typeof t === 'string' ? t : t.heading
if (trackName === 'Builder/Maintainer') return 'Builder / Maintainer'
return trackName
}
export async function POST(req: Request) {
if (!NOTION_API_KEY || !NOTION_DB_ID) {
captureSentryCommunityException(new Error('Server misconfigured: missing Notion credentials'))
return new Response(
JSON.stringify({ message: 'Server misconfigured: missing Notion credentials' }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
)
}
let body: unknown
try {
body = await req.json()
} catch (error: any) {
captureSentryCommunityException(new Error('Unable to parse JSON:`'))
return new Response(JSON.stringify({ message: 'Invalid JSON' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
const parsed = applicationSchema.safeParse(body)
if (!parsed.success) {
return new Response(JSON.stringify({ message: parsed.error.flatten() }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 422,
})
}
const data = parsed.data
try {
const notionProps = getNotionPageProps(data)
const notionPageId = await insertPageInDatabase(NOTION_DB_ID, NOTION_API_KEY, notionProps)
await savePersonAndEventInCustomerIO({
...data,
tracks: data.tracks.map(normalizeTrack),
notion_page_id: notionPageId,
source_url: req.headers.get('origin'),
})
return new Response(JSON.stringify({ message: 'Submission successful', id: notionPageId }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 201,
})
} catch (err: any) {
console.error(err)
captureSentryCommunityException(err)
return new Response(
JSON.stringify({ message: 'Error sending your application', error: err?.message }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 502,
}
)
}
}
const savePersonAndEventInCustomerIO = async (data: any) => {
const customerioSiteId = process.env.CUSTOMERIO_SITE_ID
const customerioApiKey = process.env.CUSTOMERIO_API_KEY
if (customerioSiteId && customerioApiKey) {
try {
const customerioClient = new CustomerioTrackClient(customerioSiteId, customerioApiKey)
// Create or update profile in Customer.io
// Note: only include personal information
// not application specific responses
await customerioClient.createOrUpdateProfile(data.email, {
first_name: data.first_name,
last_name: data.last_name,
email: data.email,
city: data.city,
country: data.country,
github: data.github,
twitter: data.twitter,
})
// Track the supasquad_application_form_submitted event
// This includes application specific responses
const customerioEvent = {
userId: data.email,
type: 'track' as const,
event: 'supasquad_application_form_submitted',
properties: {
...data,
event_type: 'supasquad_application_form_submitted',
source: 'supasquad_application_form',
submitted_at: new Date().toISOString(),
},
timestamp: customerioClient.isoToUnixTimestamp(new Date().toISOString()),
}
await customerioClient.trackEvent(data.email, customerioEvent)
await sendConfirmationEmail({
email: data.email,
first_name: data.first_name,
last_name: data.last_name,
})
} catch (error) {
console.error('Customer.io Track API integration failed:', error)
}
}
}
const sendConfirmationEmail = async (emailData: {
email: string
first_name: string
last_name: string
}) => {
const customerioApiKey = process.env.CUSTOMERIO_APP_API_KEY
if (customerioApiKey) {
const customerioAppClient = new CustomerioAppClient(customerioApiKey)
try {
const emailRequest = {
transactional_message_id: 9,
to: emailData.email,
identifiers: {
email: emailData.email,
},
message_data: {
first_name: emailData.first_name,
last_name: emailData.last_name,
},
send_at: customerioAppClient.isoToUnixTimestamp(
new Date(Date.now() + 60 * 1000).toISOString()
), // Schedule to send after 1 minute
}
await customerioAppClient.sendTransactionalEmail(emailRequest)
} catch (error) {
throw new Error(`Failed to send confirmation email: ${error}`)
}
} else {
console.warn('Customer.io App API key is not set')
}
}
const getNotionPageProps = (data: any) => {
const fullName =
`${data.first_name?.trim() || ''} ${data.last_name?.trim() || ''}`.trim() || 'Unnamed'
const props: Record<string, any> = {
Name: {
title: [{ type: 'text', text: { content: fullName } }],
},
'First name': {
rich_text: [{ type: 'text', text: { content: data.first_name || '' } }],
},
'Last name': {
rich_text: [{ type: 'text', text: { content: data.last_name || '' } }],
},
Email: { email: data.email },
'What track would you like to be considered for?': {
multi_select: asMultiSelect(data.tracks.map(normalizeTrack)),
},
'Product areas of interest': {
multi_select: asMultiSelect(data.areas_of_interest),
},
'Languages spoken': {
multi_select: asMultiSelect(data.languages_spoken),
},
'Date submitted': {
date: { start: new Date().toISOString().split('T')[0] },
},
Country: {
select: { name: data.country },
},
City: {
rich_text: [{ type: 'text', text: { content: truncateRichText(data.city, 120) } }],
},
Location: {
rich_text: [
{ type: 'text', text: { content: truncateRichText(data.city + ', ' + data.country, 120) } },
],
},
}
if (data.monthly_commitment) {
props['Monthly commitment'] = {
rich_text: [{ type: 'text', text: { content: truncateRichText(data.monthly_commitment) } }],
}
}
if (data.skills) {
props['Skills (frameworks, tools, languages)'] = {
rich_text: [{ type: 'text', text: { content: truncateRichText(data.skills) } }],
}
}
if (data.why_you_want_to_join) {
props['Why do you want to join the program'] = {
rich_text: [
{ type: 'text', text: { content: truncateRichText(data.why_you_want_to_join, 1800) } },
],
}
}
if (data.github) {
props['GitHub Profile'] = {
rich_text: [{ type: 'text', text: { content: data.github } }],
}
}
if (data.twitter) {
props['Twitter handle'] = {
rich_text: [{ type: 'text', text: { content: data.twitter } }],
}
}
return props
}
@@ -0,0 +1,698 @@
import { FC, useEffect, useState, memo } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Button,
Form_Shadcn_,
FormField_Shadcn_,
FormLabel_Shadcn_,
FormControl_Shadcn_,
FormItem_Shadcn_,
Input_Shadcn_,
FormMessage_Shadcn_,
Separator,
TextArea_Shadcn_,
FormDescription_Shadcn_,
} from 'ui'
import { Alert, AlertTitle, AlertDescription } from 'ui/src/components/shadcn/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from 'ui/src/components/shadcn/ui/alert-dialog'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorList,
MultiSelectorTrigger,
MultiSelectorItem,
} from 'ui-patterns/multi-select'
import { CountrySelector } from '../Supasquad/CountrySelector'
interface FormItem_Shadcn_ {
type: 'text' | 'textarea'
label: string
placeholder: string
required: boolean
className?: string
component: typeof TextArea_Shadcn_ | typeof Input_Shadcn_
}
interface Track {
heading: string
description: string
}
interface Props {
className?: 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',
'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 applicationSchema = z.object({
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required'),
email: z.string().email('Please enter a valid email address'),
tracks: z
.array(
z.object({
heading: z.string(),
description: z.string(),
})
)
.min(1, 'Select at least 1 track'),
areas_of_interest: z.array(z.string()).min(1, 'Select at least 1 area of interest'),
why_you_want_to_join: z.string().min(1, 'This is required'),
monthly_commitment: z.string().optional(),
languages_spoken: z.array(z.string()).min(1, 'Select at least 1 language'),
skills: z.string().optional(),
city: z.string().min(1, 'Specify your city'),
country: z.string().min(1, 'Specify your country'),
github: z.string().optional(),
twitter: z.string().optional(),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const headerContent = {
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 FormContent = memo(function FormContent({
form,
errors,
onSubmit,
honeypot,
setHoneypot,
isMobile,
isSubmitted,
handleCancel,
isSubmitting,
}: {
form: ReturnType<typeof useForm<ApplicationFormData>>
errors: { [key: string]: string }
onSubmit: (data: ApplicationFormData) => 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_Shadcn_ {...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_Shadcn_
control={form.control}
name="first_name"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full md:flex-1">
<FormLabel_Shadcn_ className="text-foreground">First Name *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_
placeholder="Your first name"
autoComplete="given-name"
{...field}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="last_name"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full md:flex-1">
<FormLabel_Shadcn_ className="text-foreground">Last Name *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="relative ">
<Input_Shadcn_
placeholder="Your last name"
autoComplete="family-name"
{...field}
/>
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<FormField_Shadcn_
control={form.control}
name="email"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Email Address *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="relative">
<Input_Shadcn_
type="email"
placeholder="Your personal or work email"
autoComplete="email"
{...field}
/>
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Separator />
<div className="space-y-8">
<h3 className="h3 text-foreground">Interests and skills</h3>
<FormField_Shadcn_
control={form.control}
name="why_you_want_to_join"
render={({ field }) => (
<FormItem_Shadcn_ className="space-y-1">
<FormLabel_Shadcn_ className="text-foreground">
Why do you want to join the program? *
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
What do you have to contribute? What would you like to get out of it?
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<div className="relative">
<TextArea_Shadcn_ autoComplete="off" {...field} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="tracks"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
What track would you like to be considered for? *
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
See longer descriptions of the 4 options above
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<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_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Separator />
<FormField_Shadcn_
control={form.control}
name="areas_of_interest"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Product Areas of Interest *
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
What specific areas would you like to help with? Leave blank if you're not sure.
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<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_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="skills"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Skills (frameworks, tools, programming languages)
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
Know Postgres really well? React? Python? Rust? Terraform? Add it here!
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<div className="relative mt-1">
<Input_Shadcn_ type="text" {...field} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</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_Shadcn_
control={form.control}
name="country"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full md:flex-1">
<FormLabel_Shadcn_ className="text-foreground">Country *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="relative mt-1">
{/* <Input_Shadcn_ type="text" placeholder="Country" {...field} /> */}
<CountrySelector value={field.value || ''} onValueChange={field.onChange} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="city"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full md:flex-1">
<FormLabel_Shadcn_ className="text-foreground">City *</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="relative mt-1">
<Input_Shadcn_ type="text" placeholder="City" {...field} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<FormField_Shadcn_
control={form.control}
name="monthly_commitment"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Monthly Commitment
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
How many hours can you commit per month? If not sure, leave blank.
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<div className="relative mt-1">
<Input_Shadcn_ {...field} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="languages_spoken"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">
Languages spoken *
</FormLabel_Shadcn_>
<FormDescription_Shadcn_ className="text-foreground-lighter">
What languages do you speak?
</FormDescription_Shadcn_>
<FormControl_Shadcn_>
<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_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
<Separator />
<div className="space-y-6">
<h3 className="h4 text-foreground">Social Links</h3>
<div className="space-y-3">
<FormField_Shadcn_
control={form.control}
name="github"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">GitHub</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="relative mt-1">
<Input_Shadcn_ type="text" placeholder="@yourusername" {...field} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="twitter"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground">Twitter</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="relative mt-1">
<Input_Shadcn_ type="text" placeholder="@yourhandle" {...field} />
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</div>
</div>
{!isSubmitted && (
<div className="flex flex-row gap-3">
<Button
size="small"
htmlType="button"
type="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="border-border text-foreground hover:bg-muted flex-1"
>
Cancel
</Button>
<Button size="small" htmlType="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_Shadcn_>
{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 }) => {
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<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
first_name: '',
last_name: '',
email: '',
tracks: [],
areas_of_interest: [],
skills: '',
why_you_want_to_join: '',
city: '',
country: '',
monthly_commitment: '',
languages_spoken: [],
github: '',
twitter: '',
},
mode: 'onBlur',
reValidateMode: 'onBlur',
})
const handleCancel = () => {
form.reset()
setIsSubmitted(false)
}
const handleConfirmationClose = () => {
form.reset()
setShowConfirmation(false)
setIsSubmitted(false)
setErrors({})
}
const onSubmit = async (data: ApplicationFormData) => {
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">{headerContent.title}</h2>
<p className="text-muted-foreground">{headerContent.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 className="z-[60]">
<AlertDialogTitle className="sr-only">Application Submitted</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
Your application has been successfully submitted. Please check your email for
confirmation.
</AlertDialogDescription>
<div className="flex flex-col items-center gap-6 py-4">
<div className="flex items-center justify-center w-16 h-16 bg-green-100 rounded-full">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<div className="text-center space-y-2">
<h3 className="text-xl font-semibold">Application Submitted!</h3>
<p className="text-muted-foreground">
Thank you for your submission. Please check your email for a confirmation link to
complete your application.
</p>
</div>
<AlertDialogAction onClick={handleConfirmationClose} className="w-full max-w-xs">
Got it, thanks!
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default ApplyToSupaSquadForm
@@ -63,7 +63,7 @@ const FeatureItem: FC<FeatureItemProps> = ({ feature }) => {
/>
</svg>
) : (
<Icon className={cn('stroke-1 mb-2 text-current', iconWidth, iconHeight)} />
<Icon className={cn('mb-2 text-current', iconWidth, iconHeight)} strokeWidth={1.5} />
))}
<div className="w-full h-px overflow-hidden flex items-start bg-border-muted">
<span className={cn('h-full bg-foreground-lighter', iconWidth)} />
@@ -0,0 +1,40 @@
import React from 'react'
import { cn } from 'ui'
import SectionContainer from '~/components/Layouts/SectionContainer'
import ApplyToSupaSquadForm from '~/components/Forms/ApplyToSupaSquadForm'
interface Props {
id: string
title: string | React.ReactNode
subtitle?: string
cta: {
label: string
icon?: React.ReactNode
}
image?: {
dark: string
light: string
}
className?: string
}
const ApplicationFormSection = ({ id, title, subtitle, cta, className }: Props) => {
return (
<SectionContainer
id={id}
className={cn('py-16 md:py-24 lg:py-32 relative overflow-hidden', className)}
>
<div className="mx-auto relative z-10">
<div className="flex flex-col gap-6 text-center items-center max-w-5xl mx-auto">
<h2 className="text-foreground-light text-2xl lg:text-3xl leading-tight">{title}</h2>
{subtitle && <p className="text-foreground-light text-lg">{subtitle}</p>}
<ApplyToSupaSquadForm />
</div>
</div>
<div className="absolute inset-0 w-full h-full bg-gradient-to-r from-transparent via-transparent to-background opacity-80 z-0"></div>
</SectionContainer>
)
}
export default ApplicationFormSection
@@ -0,0 +1,192 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from 'ui/src/components/shadcn/ui/select'
// Country data with flags and names (alphabetically sorted)
const countries = [
{ code: 'AF', name: 'Afghanistan', flag: '🇦🇫' },
{ code: 'AL', name: 'Albania', flag: '🇦🇱' },
{ code: 'DZ', name: 'Algeria', flag: '🇩🇿' },
{ code: 'AO', name: 'Angola', flag: '🇦🇴' },
{ code: 'AR', name: 'Argentina', flag: '🇦🇷' },
{ code: 'AM', name: 'Armenia', flag: '🇦🇲' },
{ code: 'AU', name: 'Australia', flag: '🇦🇺' },
{ code: 'AT', name: 'Austria', flag: '🇦🇹' },
{ code: 'AZ', name: 'Azerbaijan', flag: '🇦🇿' },
{ code: 'BH', name: 'Bahrain', flag: '🇧🇭' },
{ code: 'BD', name: 'Bangladesh', flag: '🇧🇩' },
{ code: 'BY', name: 'Belarus', flag: '🇧🇾' },
{ code: 'BE', name: 'Belgium', flag: '🇧🇪' },
{ code: 'BJ', name: 'Benin', flag: '🇧🇯' },
{ code: 'BT', name: 'Bhutan', flag: '🇧🇹' },
{ code: 'BO', name: 'Bolivia', flag: '🇧🇴' },
{ code: 'BW', name: 'Botswana', flag: '🇧🇼' },
{ code: 'BR', name: 'Brazil', flag: '🇧🇷' },
{ code: 'BF', name: 'Burkina Faso', flag: '🇧🇫' },
{ code: 'BI', name: 'Burundi', flag: '🇧🇮' },
{ code: 'BG', name: 'Bulgaria', flag: '🇧🇬' },
{ code: 'CA', name: 'Canada', flag: '🇨🇦' },
{ code: 'CV', name: 'Cape Verde', flag: '🇨🇻' },
{ code: 'CF', name: 'Central African Republic', flag: '🇨🇫' },
{ code: 'TD', name: 'Chad', flag: '🇹🇩' },
{ code: 'CL', name: 'Chile', flag: '🇨🇱' },
{ code: 'CN', name: 'China', flag: '🇨🇳' },
{ code: 'CO', name: 'Colombia', flag: '🇨🇴' },
{ code: 'KM', name: 'Comoros', flag: '🇰🇲' },
{ code: 'CG', name: 'Republic of Congo', flag: '🇨🇬' },
{ code: 'CD', name: 'DR Congo', flag: '🇨🇩' },
{ code: 'HR', name: 'Croatia', flag: '🇭🇷' },
{ code: 'CY', name: 'Cyprus', flag: '🇨🇾' },
{ code: 'CZ', name: 'Czech Republic', flag: '🇨🇿' },
{ code: 'DK', name: 'Denmark', flag: '🇩🇰' },
{ code: 'DJ', name: 'Djibouti', flag: '🇩🇯' },
{ code: 'EC', name: 'Ecuador', flag: '🇪🇨' },
{ code: 'EG', name: 'Egypt', flag: '🇪🇬' },
{ code: 'ER', name: 'Eritrea', flag: '🇪🇷' },
{ code: 'EE', name: 'Estonia', flag: '🇪🇪' },
{ code: 'ET', name: 'Ethiopia', flag: '🇪🇹' },
{ code: 'FK', name: 'Falkland Islands', flag: '🇫🇰' },
{ code: 'FJ', name: 'Fiji', flag: '🇫🇯' },
{ code: 'FI', name: 'Finland', flag: '🇫🇮' },
{ code: 'FR', name: 'France', flag: '🇫🇷' },
{ code: 'PF', name: 'French Polynesia', flag: '🇵🇫' },
{ code: 'GF', name: 'French Guiana', flag: '🇬🇫' },
{ code: 'GA', name: 'Gabon', flag: '🇬🇦' },
{ code: 'GM', name: 'Gambia', flag: '🇬🇲' },
{ code: 'GE', name: 'Georgia', flag: '🇬🇪' },
{ code: 'DE', name: 'Germany', flag: '🇩🇪' },
{ code: 'GH', name: 'Ghana', flag: '🇬🇭' },
{ code: 'GR', name: 'Greece', flag: '🇬🇷' },
{ code: 'GN', name: 'Guinea', flag: '🇬🇳' },
{ code: 'GW', name: 'Guinea-Bissau', flag: '🇬🇼' },
{ code: 'GY', name: 'Guyana', flag: '🇬🇾' },
{ code: 'HK', name: 'Hong Kong', flag: '🇭🇰' },
{ code: 'HU', name: 'Hungary', flag: '🇭🇺' },
{ code: 'IN', name: 'India', flag: '🇮🇳' },
{ code: 'ID', name: 'Indonesia', flag: '🇮🇩' },
{ code: 'IQ', name: 'Iraq', flag: '🇮🇶' },
{ code: 'IE', name: 'Ireland', flag: '🇮🇪' },
{ code: 'IL', name: 'Israel', flag: '🇮🇱' },
{ code: 'IT', name: 'Italy', flag: '🇮🇹' },
{ code: 'CI', name: 'Ivory Coast', flag: '🇨🇮' },
{ code: 'JP', name: 'Japan', flag: '🇯🇵' },
{ code: 'JO', name: 'Jordan', flag: '🇯🇴' },
{ code: 'KZ', name: 'Kazakhstan', flag: '🇰🇿' },
{ code: 'KE', name: 'Kenya', flag: '🇰🇪' },
{ code: 'KH', name: 'Cambodia', flag: '🇰🇭' },
{ code: 'KW', name: 'Kuwait', flag: '🇰🇼' },
{ code: 'LA', name: 'Laos', flag: '🇱🇦' },
{ code: 'LV', name: 'Latvia', flag: '🇱🇻' },
{ code: 'LB', name: 'Lebanon', flag: '🇱🇧' },
{ code: 'LS', name: 'Lesotho', flag: '🇱🇸' },
{ code: 'LR', name: 'Liberia', flag: '🇱🇷' },
{ code: 'LY', name: 'Libya', flag: '🇱🇾' },
{ code: 'LT', name: 'Lithuania', flag: '🇱🇹' },
{ code: 'MG', name: 'Madagascar', flag: '🇲🇬' },
{ code: 'MW', name: 'Malawi', flag: '🇲🇼' },
{ code: 'MY', name: 'Malaysia', flag: '🇲🇾' },
{ code: 'MV', name: 'Maldives', flag: '🇲🇻' },
{ code: 'ML', name: 'Mali', flag: '🇲🇱' },
{ code: 'MT', name: 'Malta', flag: '🇲🇹' },
{ code: 'MR', name: 'Mauritania', flag: '🇲🇷' },
{ code: 'MU', name: 'Mauritius', flag: '🇲🇺' },
{ code: 'MX', name: 'Mexico', flag: '🇲🇽' },
{ code: 'MD', name: 'Moldova', flag: '🇲🇩' },
{ code: 'MN', name: 'Mongolia', flag: '🇲🇳' },
{ code: 'MM', name: 'Myanmar', flag: '🇲🇲' },
{ code: 'MZ', name: 'Mozambique', flag: '🇲🇿' },
{ code: 'NA', name: 'Namibia', flag: '🇳🇦' },
{ code: 'NC', name: 'New Caledonia', flag: '🇳🇨' },
{ code: 'NP', name: 'Nepal', flag: '🇳🇵' },
{ code: 'NL', name: 'Netherlands', flag: '🇳🇱' },
{ code: 'NZ', name: 'New Zealand', flag: '🇳🇿' },
{ code: 'NE', name: 'Niger', flag: '🇳🇪' },
{ code: 'NG', name: 'Nigeria', flag: '🇳🇬' },
{ code: 'KP', name: 'North Korea', flag: '🇰🇵' },
{ code: 'NO', name: 'Norway', flag: '🇳🇴' },
{ code: 'OM', name: 'Oman', flag: '🇴🇲' },
{ code: 'PK', name: 'Pakistan', flag: '🇵🇰' },
{ code: 'PG', name: 'Papua New Guinea', flag: '🇵🇬' },
{ code: 'PY', name: 'Paraguay', flag: '🇵🇾' },
{ code: 'PE', name: 'Peru', flag: '🇵🇪' },
{ code: 'PH', name: 'Philippines', flag: '🇵🇭' },
{ code: 'PL', name: 'Poland', flag: '🇵🇱' },
{ code: 'PT', name: 'Portugal', flag: '🇵🇹' },
{ code: 'QA', name: 'Qatar', flag: '🇶🇦' },
{ code: 'RO', name: 'Romania', flag: '🇷🇴' },
{ code: 'RU', name: 'Russia', flag: '🇷🇺' },
{ code: 'RW', name: 'Rwanda', flag: '🇷🇼' },
{ code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' },
{ code: 'SC', name: 'Seychelles', flag: '🇸🇨' },
{ code: 'SG', name: 'Singapore', flag: '🇸🇬' },
{ code: 'SK', name: 'Slovakia', flag: '🇸🇰' },
{ code: 'SI', name: 'Slovenia', flag: '🇸🇮' },
{ code: 'SB', name: 'Solomon Islands', flag: '🇸🇧' },
{ code: 'SO', name: 'Somalia', flag: '🇸🇴' },
{ code: 'ZA', name: 'South Africa', flag: '🇿🇦' },
{ code: 'KR', name: 'South Korea', flag: '🇰🇷' },
{ code: 'SS', name: 'South Sudan', flag: '🇸🇸' },
{ code: 'ES', name: 'Spain', flag: '🇪🇸' },
{ code: 'LK', name: 'Sri Lanka', flag: '🇱🇰' },
{ code: 'SR', name: 'Suriname', flag: '🇸🇷' },
{ code: 'SE', name: 'Sweden', flag: '🇸🇪' },
{ code: 'CH', name: 'Switzerland', flag: '🇨🇭' },
{ code: 'SY', name: 'Syria', flag: '🇸🇾' },
{ code: 'SZ', name: 'Eswatini', flag: '🇸🇿' },
{ code: 'TW', name: 'Taiwan', flag: '🇹🇼' },
{ code: 'TZ', name: 'Tanzania', flag: '🇹🇿' },
{ code: 'TH', name: 'Thailand', flag: '🇹🇭' },
{ code: 'TL', name: 'Timor-Leste', flag: '🇹🇱' },
{ code: 'TG', name: 'Togo', flag: '🇹🇬' },
{ code: 'TR', name: 'Turkey', flag: '🇹🇷' },
{ code: 'UG', name: 'Uganda', flag: '🇺🇬' },
{ code: 'UA', name: 'Ukraine', flag: '🇺🇦' },
{ code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪' },
{ code: 'GB', name: 'United Kingdom', flag: '🇬🇧' },
{ code: 'US', name: 'United States', flag: '🇺🇸' },
{ code: 'UY', name: 'Uruguay', flag: '🇺🇾' },
{ code: 'VU', name: 'Vanuatu', flag: '🇻🇺' },
{ code: 'VE', name: 'Venezuela', flag: '🇻🇪' },
{ code: 'VN', name: 'Vietnam', flag: '🇻🇳' },
{ code: 'ZM', name: 'Zambia', flag: '🇿🇲' },
{ code: 'ZW', name: 'Zimbabwe', flag: '🇿🇼' },
{ code: 'Other', name: 'Other', flag: '🌍' },
]
interface CountrySelectorProps {
value: string
onValueChange: (value: string) => void
placeholder?: string
}
export function CountrySelector({
value,
onValueChange,
placeholder = 'Select your country',
}: CountrySelectorProps) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder}>
{value && (
<span className="flex items-center gap-2">
<span>{countries.find((c) => c.code === value)?.flag}</span>
{countries.find((c) => c.code === value)?.name}
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{countries.map((country) => (
<SelectItem key={country.code} value={country.code}>
<span className="flex items-center gap-2">{country.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,57 @@
import Link from 'next/link'
import React from 'react'
import { Button, cn, Image } from 'ui'
import SectionContainer from '~/components/Layouts/SectionContainer'
interface Props {
id: string
title: string | React.ReactNode
subtitle?: string
primaryCta: {
label: string
url: string
target?: string
icon?: React.ReactNode
}
secondaryCta?: {
label: string
url: string
}
image?: {
dark: string
light: string
}
className?: string
}
const CtaSection = ({ id, title, subtitle, primaryCta, secondaryCta, className }: Props) => {
return (
<SectionContainer
id={id}
className={cn('py-16 md:py-24 lg:py-32 relative overflow-hidden', className)}
>
<div className="mx-auto relative z-10">
<div className="flex flex-col gap-6 text-center items-center max-w-5xl mx-auto">
<h2 className="text-foreground-light text-2xl lg:text-3xl leading-tight">{title}</h2>
{subtitle && <p className="text-foreground-light text-lg">{subtitle}</p>}
<div className="flex flex-wrap gap-3 pt-4">
<Button asChild size="medium" icon={primaryCta.icon}>
<Link href={primaryCta.url} target={primaryCta.target}>
{primaryCta.label}
</Link>
</Button>
{secondaryCta && (
<Button asChild size="medium" type="default">
<Link href={secondaryCta.url}>{secondaryCta.label}</Link>
</Button>
)}
</div>
</div>
</div>
<div className="absolute inset-0 w-full h-full bg-gradient-to-r from-transparent via-transparent to-background opacity-80 z-0"></div>
</SectionContainer>
)
}
export default CtaSection
@@ -0,0 +1,81 @@
import React, { FC } from 'react'
import { cn } from 'ui'
import type { Feature } from '~/data/open-source/contributing/supasquad.utils'
import {
Award,
Zap,
MessageSquare,
DollarSign,
Gift,
TrendingUp,
Heart,
LifeBuoy,
Wrench,
Shield,
} from 'lucide-react'
const ICONS = {
award: Award,
zap: Zap,
'message-square': MessageSquare,
'dollar-sign': DollarSign,
gift: Gift,
'trending-up': TrendingUp,
heart: Heart,
'life-buoy': LifeBuoy,
wrench: Wrench,
shield: Shield,
} as const
type IconName = keyof typeof ICONS
// Type guard to check if a string is a valid icon name
function isValidIconName(icon: string): icon is IconName {
return icon in ICONS
}
interface FeatureIconProps {
icon: Feature['icon']
iconNoStroke: Feature['iconNoStroke']
strokeWidth?: number
}
const FeatureIcon: FC<FeatureIconProps> = ({ icon, iconNoStroke, strokeWidth = 1.5 }) => {
const Icon = icon && isValidIconName(icon) ? ICONS[icon] : null
const iconSize = 7
const iconWidth = `w-${iconSize}`
const iconHeight = `h-${iconSize}`
return (
Icon &&
(typeof Icon === 'string' ? (
<svg
width="25"
height="25"
viewBox="0 0 25 25"
fill={iconNoStroke ? 'currentColor' : 'none'}
className="w-7 h-7 mb-2"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={Icon}
fillRule="evenodd"
clipRule="evenodd"
stroke={iconNoStroke ? 'none' : 'currentColor'}
strokeMiterlimit="10"
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
</svg>
) : (
<Icon
className={cn('mb-2 text-foreground', iconWidth, iconHeight)}
strokeWidth={strokeWidth}
/>
))
)
}
export default FeatureIcon
@@ -0,0 +1,52 @@
import React, { FC } from 'react'
import dynamic from 'next/dynamic'
import { cn } from 'ui'
import SectionContainer from '~/components/Layouts/SectionContainer'
import type {
Feature,
FeaturesSection as FeaturesSectionType,
} from '~/data/open-source/contributing/supasquad.utils'
const FeatureIcon = dynamic(() => import('~/components/Supasquad/FeatureIcon'), { ssr: false })
const FeaturesSection = (props: FeaturesSectionType) => {
return (
<SectionContainer id={props.id} className={cn('flex flex-col gap-4 md:gap-8', props.className)}>
<div className="flex flex-col gap-2 max-w-xl">
<span className="label">{props.label}</span>
<h2 className="h2 text-foreground-lighter">{props.heading}</h2>
{props.subheading && <p className="text-foreground-lighter mb-8">{props.subheading}</p>}
</div>
<ul
className={cn(
'grid grid-cols-1 gap-4 gap-y-10 md:grid-cols-3 md:gap-12 xl:gap-20',
props.features?.length === 4 && 'md:grid-cols-2 xl:grid-cols-4'
)}
>
{props.features?.map((feature: Feature, index: number) => (
<FeatureItem feature={feature} key={feature.id} />
))}
</ul>
</SectionContainer>
)
}
interface FeatureItemProps {
feature: Feature
}
const FeatureItem: FC<FeatureItemProps> = ({ feature }) => {
return (
<li className="flex flex-col gap-2 text-sm text-foreground-lighter">
<FeatureIcon icon={feature.icon} iconNoStroke={feature.iconNoStroke} />
<div className="w-full h-px overflow-hidden flex items-start bg-border-muted">
<span className={cn('h-full bg-foreground-lighter', 'h-7')} />
</div>
<h4 className="text-foreground text-lg lg:text-xl mt-1">{feature.heading}</h4>
<p className="text-foreground-lighter text-sm">{feature.subheading}</p>
{/* <TextLink hasChevron label="Read story" url={feature.url} className="mt-4" /> */}
</li>
)
}
export default FeaturesSection
@@ -0,0 +1,110 @@
import React, { FC } from 'react'
import { cn, Badge } from 'ui'
import SectionContainer from '~/components/Layouts/SectionContainer'
export interface PerfectTimingProps {
id: string
heading: string | JSX.Element
subheading: string | JSX.Element
highlights: Highlight[]
}
type Highlight = {
heading: string
subheading: string
}
const PerfectTiming = (props: PerfectTimingProps) => {
return (
<SectionContainer id={props.id} className="relative">
<div className="relative z-10 flex flex-col gap-4 md:gap-8 pb-20">
<div className="flex flex-col gap-2 max-w-xl">
{/* <span className="label">{props.label}</span> */}
<h2 className="h2 !m-0">{props.heading}</h2>
<p className="p !text-foreground-lighter">{props.subheading}</p>
</div>
<div className="flex flex-wrap gap-4 md:gap-12">
{props.highlights.map((highlight) => (
<HighlightItem key={highlight.heading} highlight={highlight} />
))}
</div>
</div>
<div className="relative xl:absolute z-0 inset-0 mt-4 -mb-8 sm:mt-0 sm:-mb-20 md:-mt-20 md:-mb-36 xl:mt-0 xl:top-10 w-full aspect-[2.15/1]">
<GraphLabel className="" />
<svg
width="100%"
height="100%"
viewBox="0 0 1403 599"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute inset-0 w-full h-full"
>
<path
d="M1402.27 0.744141C896.689 410.854 286.329 492.876 0.476562 492.876V598.744H1402.27V0.744141Z"
fill="url(#paint0_linear_585_9420)"
/>
<path
d="M11.4209 492.744C295.041 492.744 900.636 410.744 1402.27 0.744141"
stroke="hsl(var(--foreground-lighter))"
/>
<defs>
<linearGradient
id="paint0_linear_585_9420"
x1="701.374"
y1="170.846"
x2="701.374"
y2="561.839"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="hsl(var(--border-overlay))" />
<stop offset="1" stopColor="hsl(var(--border-overlay))" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
<div className="absolute inset-0 w-full h-full bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_0%,_hsl(var(--background-default))_100%)]" />
</div>
</SectionContainer>
)
}
const GraphLabel: FC<{ className?: string }> = ({ className }) => (
<div
className={cn(
'absolute z-10 inset-0 left-auto right-[20%] -top-8 md:top-0 xl:top-[15%] w-fit h-[200px] lg:h-[400px]',
'flex flex-col items-center gap-1',
className
)}
>
<div className="w-fit text-foreground bg-alternative p-4 rounded-lg border flex flex-col gap-1">
<span className="label !text-[10px] !leading-3">Users</span>
<div className="flex items-center gap-2">
<span className="text-foreground-light text-2xl">230,550</span>
<Badge variant="success" size="small" className="h-[24px] px-2">
+13.4%
</Badge>
</div>
</div>
<div
className={cn(
'relative w-2 h-2 min-w-2 min-h-2 rounded-full border-2 border-stronger',
'after:absolute after:inset-0 after:top-full after:mx-auto after:w-[2px] after:h-[150px] after:lg:h-[250px]',
'after:bg-gradient-to-b after:from-border-stronger after:to-transparent'
)}
/>
</div>
)
interface HighlightItemProps {
highlight: Highlight
}
const HighlightItem: FC<HighlightItemProps> = ({ highlight }) => {
return (
<li className="flex flex-col gap-2 text-sm">
<span className="label">{highlight.heading}</span>
<p className="text-foreground text-xl md:text-3xl">{highlight.subheading}</p>
</li>
)
}
export default PerfectTiming
+90
View File
@@ -0,0 +1,90 @@
import 'swiper/css'
import Link from 'next/link'
import { FC } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react'
import SectionContainer from '~/components/Layouts/SectionContainer'
import Panel from '~/components/Panel'
import type { Quote, Quotes } from '~/data/solutions/solutions.utils'
import Image from 'next/image'
const Quotes: FC<Quotes> = (props) => (
<section id={props.id}>
<div className="overflow-hidden">
<SectionContainer className="!py-4">
<ul className="hidden xl:flex flex-col gap-4 md:flex-row items-stretch w-full h-auto min-h-64">
{props.items?.map((quote: Quote) => (
<li key={quote.author} className="w-full">
<QuoteCard {...quote} />
</li>
))}
</ul>
<div className="xl:hidden">
<Swiper
style={{ zIndex: 0, marginRight: '1px' }}
initialSlide={0}
spaceBetween={12}
slidesPerView={1.2}
speed={300}
watchOverflow
threshold={2}
updateOnWindowResize
className="h-[300px] w-full !overflow-visible"
breakpoints={{
320: {
slidesPerView: 1.2,
},
540: {
slidesPerView: 1.5,
},
900: {
slidesPerView: 2.5,
},
}}
>
{props.items?.map((quote: Quote, i: number) => (
<SwiperSlide key={`${i}-mobile`}>
<QuoteCard {...quote} />
</SwiperSlide>
))}
</Swiper>
</div>
</SectionContainer>
</div>
</section>
)
const QuoteCard: FC<Quote> = ({ quote, author, avatar, authorTitle }) => {
return (
<Panel
outerClassName="w-full h-full"
innerClassName="flex flex-col justify-between text-foreground-lighter bg-surface-75 p-5"
>
<div className="flex flex-col justify-between gap-6">
<q className="text-base">{quote}</q>
</div>
<div className="flex flex-row gap-3 w-full items-center">
<Image
src={avatar}
alt={author}
width={32}
height={32}
className="bg-surface-200 rounded-full border flex-shrink-0"
/>
<div className="flex flex-col gap-0">
<span className="text-base text-foreground-light leading-snug">{author}</span>
{authorTitle && (
<span className="uppercase font-mono text-sm text-foreground-lighter leading-tight">
{authorTitle}
</span>
)}
</div>
</div>
</Panel>
)
}
export default Quotes
@@ -0,0 +1,210 @@
import { Image } from 'ui'
import { companyStats } from '~/data/company-stats'
export const data = {
metadata: {
metaTitle: 'SupaSquad - Supabase advocate program',
metaDescription:
'The SupaSquad is an official Supabase advocate program where community members help build and manage the Supabase community.',
},
heroSection: {
id: 'hero',
title: 'Join the squad',
h1: <>Be a Cornerstone of the Supabase Community</>,
subheader: [
<>
Join passionate contributors who shape the entire Supabase experience. From helping
developers solve problems to creating guides, advocating on social channels, and maintaining
code repositories, find your way to make a meaningful impact.
</>,
],
image: (
<Image
src={{
dark: '/images/solutions/beginners/beginners-hero-dark.svg',
light: '/images/solutions/beginners/beginners-hero-light.svg',
}}
alt="Supabase for Beginners"
className="not-sr-only"
width={1000}
height={1000}
/>
),
ctas: [
{
label: 'Learn how to join',
href: '#why-supasquad',
type: 'primary' as any,
},
],
},
quotes: {
id: 'quotes',
items: [
{
avatar:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
author: 'Sarah Chen',
authorTitle: 'SupaSquad Helper',
quote: (
<>
Being a Helper in SupaSquad has been incredibly rewarding. There's nothing like that
moment when you help someone solve a problem they've been stuck on for hours.{' '}
<span className="text-foreground">The community is so supportive</span>, and I've
learned so much by helping others work through challenges I haven't faced myself.
</>
),
},
{
avatar:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
author: 'Marcus Torres',
authorTitle: 'SupaSquad Advocate',
quote: (
<>
As an Advocate, I get to share my genuine excitement about Supabase with the broader
developer community.{' '}
<span className="text-foreground">
It's amazing to see developers discover how much faster they can build
</span>{' '}
when they don't have to worry about backend complexity.
</>
),
},
{
avatar:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
author: 'Alex Kim',
authorTitle: 'SupaSquad Maintainer',
quote: (
<>
Contributing as a Maintainer has accelerated my growth as a developer more than any
course could.
<span className="text-foreground">
Working directly with the core team on real production code
</span>{' '}
has given me insights I never would have gained otherwise.
</>
),
},
],
},
why: {
id: 'why-supasquad',
label: '',
heading: (
<>
Contribute in the way that best fits your{' '}
<span className="text-foreground">unique skills</span>
</>
),
subheading:
"We recognize that every contributor brings unique strengths, which is why we've created four distinct tracks to match how you want to make an impact. You can join one or multiple tracks based on your interests and skills.",
features: [
{
id: 'advocate',
icon: 'heart',
heading: 'Advocate',
subheading:
"Spread the word on social channels and help answer Supabase-related questions across the broader developer community. Your voice helps more builders discover what's possible.",
},
{
id: 'helper',
icon: 'life-buoy',
heading: 'Helper',
subheading:
"Share your expertise by answering questions on Discord, GitHub Discussions, and other community platforms. Help improve docs and guides that make everyone's journey smoother.",
},
{
id: 'maintainer',
icon: 'wrench',
heading: 'Maintainer',
subheading:
'Contribute to client libraries, manage issues, fix bugs, and improve the overall developer experience. Work directly with the core team to keep Supabase running smoothly.',
},
{
id: 'moderator',
icon: 'shield',
heading: 'Moderator',
subheading:
'Maintain welcoming community guidelines across GitHub, Discord, Reddit, and other platforms. Ensure our spaces remain productive and helpful for all members.',
},
],
},
timing: {
id: 'results',
heading: <>The Perfect Time to Join</>,
subheading:
"Supabase's explosive growth means more builders need help. There are more opportunities to contribute, and more ways to make your mark. Join SupaSquad and help us support this thriving ecosystem of builders.",
highlights: [
{
heading: 'databases managed',
subheading: companyStats.databasesManaged,
},
{
heading: 'databases launched daily',
subheading: companyStats.databasesLaunchedDaily,
},
],
},
benefits: {
id: 'benefits',
heading: <span className="text-foreground">Benefits for our members</span>,
subheading:
'Contributing to SupaSquad comes with real benefits. From community recognition to paid opportunities, we value your time and impact.',
features: [
{
id: 'community-recognition',
heading: 'Community Recognition',
subheading:
'Get a Badge on Discord and flair on Reddit showcasing your SupaSquad status in the community.',
icon: 'award',
},
{
id: 'early-access',
heading: 'Early Access',
subheading:
'Get first access to new Supabase features and provide feedback directly to our team.',
icon: 'zap',
},
{
id: 'direct-team-access',
heading: 'Direct Team Access',
subheading:
'Direct communication channel with Supabase team members for questions, suggestions and support.',
icon: 'message-square',
},
{
id: 'paid-contributions',
heading: 'Paid Contributions',
subheading:
'We invite top contributors to get paid for their efforts. Earn while you contribute with a stipend that recognizes the value of your time and expertise.',
icon: 'dollar-sign',
},
{
id: 'exclusive-swag',
heading: 'Exclusive SWAG',
subheading:
'Special Supabase merchandise reserved for SupaSquad members. Show your status with pride.',
icon: 'gift',
},
{
id: 'growth-opportunities',
heading: 'Growth Opportunities',
subheading:
'Room to grow from volunteer to paid contributor to paid employee. Your path in the Supabase ecosystem.',
icon: 'trending-up',
},
],
},
ctaSection: {
id: 'cta',
title: 'Ready to make an impact?',
primaryCta: {
label: 'Apply to join',
url: 'https://www.notion.so/supabase/25c5004b775f804599b7eb886a15d6b2?pvs=106',
type: 'primary' as any,
},
},
}
@@ -0,0 +1,226 @@
import { useBreakpoint } from 'common'
import { LucideIcon } from 'lucide-react'
import Link from 'next/link'
import type { ComponentType, SVGProps } from 'react'
import { cn } from 'ui'
export type HeroIcon = ComponentType<SVGProps<SVGSVGElement>>
export type IconType = LucideIcon | HeroIcon
export interface Metadata {
metaTitle: string
metaDescription: string
}
export interface HeroSection {
id: string
title: string
h1: JSX.Element
subheader: JSX.Element[]
image: JSX.Element
className?: string
sectionContainerClassName?: string
icon?: string
ctas: {
label: string
href: string
type: 'primary' | 'default'
}[]
logos?: {
name: string
image: string
}[]
footer?: React.ReactNode
footerPosition?: 'left' | 'right' | 'bottom'
}
export interface Quote {
icon?: string
author: string
authorTitle?: string
quote: JSX.Element
avatar: string
}
export interface Quotes {
id: string
items: Quote[]
}
export interface Highlight {
icon?: IconType
heading: string | JSX.Element
subheading: string | JSX.Element
url?: string
}
export interface Feature {
id?: string
icon?: string
iconNoStroke?: boolean
heading: string | JSX.Element
subheading: string | JSX.Element
img?: JSX.Element
}
export interface FeaturesSection {
id: string
label?: string
heading: JSX.Element
subheading?: string | JSX.Element
features: Feature[]
className?: string
// {
// [key: string]: Feature
// }
}
export interface Testimonials {
id: string
label: string
heading: JSX.Element
videos: {
[key: string]: {
url: string
}
}
}
export interface CTASection {
id: string
label: string
heading: JSX.Element | string
subheading: string
cta: {
label: string
href: string
type: string
}
}
export interface FrameworkLinkProps {
name: string
icon: string | React.ReactNode
docs: string
}
export const FrameworkLink = ({ framework }: { framework: FrameworkLinkProps }) => {
const isXs = useBreakpoint(640)
return (
<Link
href={framework.docs}
className={cn(
'group relative p-4 transition-colors duration-200',
'hover:bg-surface-100 -m-px',
'flex flex-col items-center gap-2 text-center aspect-square justify-center'
)}
>
<div className="text-foreground-lighter group-hover:text-foreground transition-colors">
{typeof framework.icon === 'string' ? (
<svg
width={isXs ? 35 : 45}
height={isXs ? 35 : 45}
fillRule="evenodd"
clipRule="evenodd"
viewBox="0 0 61 61"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={framework.icon} fill="currentColor" />
</svg>
) : (
framework.icon
)}
</div>
<span className="sr-only text-sm font-medium text-foreground-light group-hover:text-foreground transition-colors">
{framework.name}
</span>
</Link>
)
}
export const getEditors: (isXs: boolean) => FrameworkLinkProps[] = (isXs) => [
{
name: 'Cursor',
icon: (
<svg
width={isXs ? 35 : 45}
height={isXs ? 35 : 45}
viewBox="0 0 61 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<mask
id="mask0_2981_701"
style={{ maskType: 'alpha' }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="80"
height="80"
>
<rect x="0.132324" width="60" height="60" fill="url(#pattern0_2981_701)" />
</mask>
<g mask="url(#mask0_2981_701)">
<rect
x="-6.99658"
y="-8.91089"
width="71.8812"
height="68.9109"
fill="url(#paint0_linear_2981_701)"
/>
</g>
<defs>
<pattern
id="pattern0_2981_701"
patternContentUnits="objectBoundingBox"
width="1"
height="1"
>
<use xlinkHref="#image0_2981_701" transform="scale(0.000976562)" />
</pattern>
<linearGradient
id="paint0_linear_2981_701"
x1="-6.99658"
y1="60"
x2="61.853"
y2="-11.8172"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="hsl(var(--foreground-default))" stopOpacity="0.5" />
<stop offset="1" stopColor="hsl(var(--foreground-default))" />
</linearGradient>
<image
id="image0_2981_701"
width="1024"
height="1024"
preserveAspectRatio="none"
xlinkHref="data:image/png;base64,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 truncated
/>
</defs>
</svg>
),
docs: '/docs/guides/getting-started/mcp#cursor',
},
{
name: 'Visual Studio Code (Copilot)',
icon: 'M50.1467 13.5721C50.2105 13.572 50.2743 13.5724 50.3382 13.576C50.3414 13.5762 50.3447 13.5768 50.3479 13.577C50.4258 13.5816 50.5036 13.5892 50.5813 13.5995C50.5895 13.6006 50.5976 13.6022 50.6057 13.6034C50.9388 13.6498 51.2689 13.7462 51.5833 13.8983L62.4924 19.1756C63.5692 19.6966 64.2777 20.757 64.3596 21.9442C64.3653 22.0231 64.3684 22.1026 64.3684 22.1825V22.3104C64.3684 22.301 64.3676 22.2914 64.3674 22.2821V57.8417C64.3675 57.834 64.3684 57.8259 64.3684 57.8182V57.9461C64.3684 57.9598 64.3666 57.9736 64.3665 57.9872C64.354 59.2535 63.6289 60.4044 62.4924 60.954L51.5833 66.2303C51.194 66.4187 50.7811 66.5227 50.3674 66.5497C50.3525 66.5507 50.3375 66.5518 50.3225 66.5526C50.2401 66.5568 50.1577 66.5585 50.0755 66.5565C49.6814 66.5509 49.2901 66.476 48.9221 66.3319C48.5051 66.1688 48.1177 65.918 47.7874 65.5858L26.9163 46.4471L17.8372 53.3749C17.4137 53.6981 16.9059 53.8466 16.4055 53.8241H16.3743C15.8739 53.8018 15.3809 53.6085 14.9876 53.2489L12.0706 50.5809C11.1081 49.7012 11.1073 48.1798 12.0686 47.2987L19.9573 40.0643L12.0676 32.8299C11.1064 31.9489 11.108 30.4273 12.0706 29.5477L14.9876 26.8797C15.3809 26.5201 15.8739 26.3269 16.3743 26.3045H16.594C17.032 26.3224 17.4668 26.4713 17.8372 26.7538L26.9163 33.6815L47.7874 14.5428C47.9113 14.4183 48.0433 14.3052 48.1819 14.204C48.7277 13.8051 49.3759 13.5895 50.0354 13.5721H50.0715C50.0966 13.5716 50.1217 13.5721 50.1467 13.5721ZM35.2825 40.0643L51.0969 52.1307V27.9969L35.2825 40.0643Z',
docs: '/docs/guides/getting-started/mcp#visual-studio-code-copilot',
},
{
name: 'Claude',
icon: 'M22.1027 49.8962L33.9052 43.2734L34.1027 42.6962L33.9052 42.3772H33.328L31.3534 42.2557L24.609 42.0734L18.7609 41.8304L13.0951 41.5266L11.6673 41.2228L10.3306 39.4608L10.4673 38.5797L11.6673 37.7747L13.3837 37.9266L17.1812 38.1848L22.8774 38.5797L27.009 38.8228L33.1306 39.4608H34.1027L34.2394 39.0658L33.9052 38.8228L33.647 38.5797L27.7534 34.5848L21.3736 30.362L18.0318 27.9316L16.2242 26.7013L15.3128 25.5468L14.9179 23.0253L16.5584 21.2177L18.7609 21.3696L19.323 21.5215L21.5559 23.238L26.3255 26.9291L32.5534 31.5165L33.4647 32.276L33.8293 32.0177L33.8749 31.8354L33.4647 31.1519L30.0774 25.0304L26.4622 18.8025L24.8521 16.2203L24.4268 14.6709C24.2749 14.0329 24.1685 13.5013 24.1685 12.8481L26.0369 10.3114L27.0698 9.97722L29.5609 10.3114L30.609 11.2228L32.1584 14.762L34.6647 20.3367L38.5534 27.9165L39.6926 30.1646L40.3002 32.2456L40.528 32.8835H40.923V32.519L41.242 28.2506L41.8344 23.0101L42.4116 16.2658L42.609 14.3671L43.5508 12.0886L45.4192 10.8582L46.8774 11.557L48.0774 13.2734L47.9103 14.3823L47.1964 19.0152L45.7989 26.276L44.8875 31.1367H45.4192L46.0268 30.5291L48.4875 27.2633L52.6192 22.0987L54.442 20.0481L56.5685 17.7848L57.9356 16.7063H60.5179L62.4166 19.5316L61.566 22.4481L58.9078 25.8203L56.7052 28.676L53.5458 32.9291L51.5711 36.3316L51.7534 36.6051L52.2242 36.5595L59.3635 35.0405L63.2217 34.3418L67.8242 33.5519L69.9053 34.5241L70.1331 35.5114L69.3128 37.5316L64.3913 38.7468L58.6192 39.9013L50.0217 41.9367L49.9154 42.0127L50.0369 42.1646L53.9103 42.5291L55.566 42.6203H59.6217L67.1711 43.1823L69.1458 44.4886L70.3306 46.0835L70.1331 47.2987L67.0951 48.8481L62.9939 47.876L53.4242 45.5975L50.1432 44.7772H49.6875V45.0506L52.4217 47.7241L57.4344 52.2506L63.7078 58.0835L64.0268 59.5266L63.2217 60.6658L62.3711 60.5443L56.8571 56.3975L54.7306 54.5291L49.9154 50.4734H49.5964V50.8987L50.7052 52.5241L56.5685 61.3342L56.8723 64.038L56.447 64.919L54.928 65.4506L53.2571 65.1468L49.8242 60.3316L46.285 54.9089L43.4293 50.0481L43.0799 50.2456L41.3939 68.3975L40.604 69.3241L38.7812 70.0228L37.2622 68.8684L36.4571 67L37.2622 63.3089L38.2344 58.4937L39.0242 54.6658L39.7382 49.9114L40.1635 48.3316L40.1331 48.2253L39.7837 48.2709L36.1989 53.1924L30.7458 60.5595L26.4318 65.1772L25.3989 65.5873L23.6065 64.6608L23.7736 63.0051L24.7761 61.5316L30.7458 53.9367L34.3458 49.2279L36.6698 46.5089L36.6546 46.1139H36.5179L20.6597 56.4127L17.8344 56.7772L16.6192 55.638L16.7711 53.7696L17.3483 53.162L22.1179 49.881L22.1027 49.8962Z',
docs: '/docs/guides/getting-started/mcp#claude-code',
},
{
name: 'Windsurf',
icon: 'M70.1801 22.6639H69.6084C66.5989 22.6592 64.1571 25.0966 64.1571 28.1059V40.2765C64.1571 42.7069 62.1485 44.6756 59.7579 44.6756C58.3377 44.6756 56.9197 43.9607 56.0785 42.7608L43.6475 25.0076C42.6163 23.5334 40.9384 22.6545 39.1219 22.6545C36.2885 22.6545 33.7386 25.0638 33.7386 28.0379V40.2788C33.7386 42.7092 31.7465 44.6779 29.3395 44.6779C27.9146 44.6779 26.499 43.9631 25.6576 42.7631L11.748 22.8983C11.434 22.4506 10.7285 22.6709 10.7285 23.2193V33.8338C10.7285 34.3705 10.8926 34.8908 11.1996 35.3314L24.8866 54.8798C25.6952 56.0352 26.8881 56.893 28.2638 57.2047C31.7066 57.9876 34.8752 55.3369 34.8752 51.9596V39.7258C34.8752 37.2954 36.844 35.3267 39.2742 35.3267H39.2812C40.7462 35.3267 42.1196 36.0415 42.9609 37.2414L55.3916 54.9924C56.4251 56.4688 58.0166 57.3455 59.9149 57.3455C62.8116 57.3455 65.2935 54.9336 65.2935 51.962V39.7234C65.2935 37.293 67.2622 35.3243 69.6927 35.3243H70.1777C70.4825 35.3243 70.7285 35.0783 70.7285 34.7736V23.2123C70.7285 22.9076 70.4825 22.6615 70.1777 22.6615L70.1801 22.6639Z',
docs: '/docs/guides/getting-started/mcp#windsurf',
},
{
name: 'Cline',
icon: 'M40.6646 10C42.5072 10 44.2747 10.7322 45.5776 12.0352C46.8803 13.338 47.6118 15.1049 47.6118 16.9473C47.6118 18.0072 47.3683 19.0415 46.9146 19.9775H53.1167C59.9917 19.9775 65.5669 25.5779 65.5669 32.4854V36.6523L69.1919 43.8926C69.3687 44.2454 69.4603 44.6347 69.4595 45.0293C69.4586 45.424 69.3654 45.813 69.187 46.165L65.5669 53.3252V57.4951C65.5668 64.4001 59.9917 70 53.1167 70H28.2144C21.337 69.9998 15.7652 64.4 15.7651 57.4951V53.3252L12.065 46.1875C11.8788 45.8299 11.7811 45.4325 11.7798 45.0293C11.7786 44.6263 11.8734 44.2288 12.0571 43.8701L15.7622 36.6523V32.4854C15.7622 25.5779 21.3374 19.9775 28.2124 19.9775H34.4146C33.9609 19.0416 33.7173 18.0071 33.7173 16.9473C33.7174 15.1048 34.4496 13.338 35.7525 12.0352C37.0553 10.7323 38.8221 10.0001 40.6646 10ZM49.5073 34C47.9996 34 46.553 34.5989 45.4868 35.665C44.4209 36.7311 43.8219 38.1771 43.8218 39.6846V49.79C43.8218 51.2976 44.4209 52.7435 45.4868 53.8096C46.553 54.8757 47.9996 55.4746 49.5073 55.4746C51.015 55.4746 52.4608 54.8757 53.5269 53.8096C54.593 52.7434 55.1919 51.2978 55.1919 49.79V39.6846C55.1918 38.9379 55.0451 38.1986 54.7593 37.5088C54.4734 36.8189 54.054 36.192 53.5259 35.6641C52.9978 35.1362 52.3711 34.7172 51.6812 34.4316C50.9912 34.1461 50.2541 33.9997 49.5073 34ZM31.1919 34C29.6843 34.0001 28.2385 34.599 27.1724 35.665C26.1063 36.7311 25.5075 38.177 25.5073 39.6846V49.79C25.5385 51.2768 26.1509 52.692 27.2134 53.7324C28.2759 54.7729 29.7038 55.3555 31.1909 55.3555C32.678 55.3555 34.106 54.7729 35.1685 53.7324C36.2309 52.692 36.8433 51.2768 36.8745 49.79V39.6846C36.8744 38.1774 36.276 36.732 35.2105 35.666C34.1449 34.5999 32.6992 34.0007 31.1919 34Z',
docs: '/docs/guides/getting-started/mcp#visual-studio-code-copilot',
},
]
+255
View File
@@ -0,0 +1,255 @@
// Utils ported from the SELECT conf website
import 'server-only'
interface CustomerioProfile {
email: string
firstName?: string
lastName?: string
[key: string]: unknown
}
interface CustomerioEvent {
userId: string
type: 'track'
event: string
properties: Record<string, unknown>
timestamp: number
}
interface CustomerioCustomer {
id: string
email: string
attributes: Record<string, unknown>
created_at: number
updated_at: number
}
export interface CustomerioSegment {
id: number
name: string
description?: string
created_at: number
updated_at: number
}
interface TransactionalEmailRequest {
transactional_message_id?: number | string
template_id?: string
to: string
from?: string
subject?: string
body?: string
message_data?: Record<string, unknown>
identifiers?: {
email?: string
id?: string
}
}
interface TransactionalEmailResponse {
delivery_id: string
queued_at: number
}
export class CustomerioTrackClient {
private baseUrl = 'https://track.customer.io/api/v1'
private auth: string
constructor(
private siteId: string,
private apiKey: string
) {
this.auth = btoa(`${siteId}:${apiKey}`)
}
private async makeRequest<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
body?: unknown
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: {
Authorization: `Basic ${this.auth}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
const errorText = await response.text()
console.error(`Customer.io API request failed: ${response.status} - ${errorText}`)
console.error(`Request URL: ${this.baseUrl}${endpoint}`)
console.error(`Request method: ${method}`)
throw new Error(`Customer.io API request failed: ${response.status} - ${errorText}`)
}
if (response.headers.get('content-type')?.includes('application/json')) {
return response.json()
}
return {} as T
}
async createOrUpdateProfile(
email: string,
attributes: Partial<CustomerioProfile> = {}
): Promise<void> {
const profile = {
email,
...attributes,
}
await this.makeRequest(`/customers/${encodeURIComponent(email)}`, 'PUT', profile)
}
async trackEvent(email: string, event: CustomerioEvent): Promise<void> {
const { userId, ...eventPayload } = event
void userId // Acknowledge unused variable
const trackEventPayload = {
name: eventPayload.event,
data: eventPayload.properties,
timestamp: eventPayload.timestamp,
}
await this.makeRequest(
`/customers/${encodeURIComponent(email)}/events`,
'POST',
trackEventPayload
)
}
isoToUnixTimestamp(isoString: string): number {
return Math.floor(new Date(isoString).getTime() / 1000)
}
}
export class CustomerioAppClient {
private baseUrl = 'https://api.customer.io'
private auth: string
constructor(private apiKey: string) {
if (!apiKey || apiKey.trim() === '') {
throw new Error('Customer.io App API key is required')
}
this.auth = apiKey.trim()
}
private async makeRequest<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
body?: unknown
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: {
Authorization: `Bearer ${this.auth}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
const errorText = await response.text()
console.error(`Customer.io App API request failed: ${response.status} - ${errorText}`)
console.error(`Request URL: ${this.baseUrl}${endpoint}`)
console.error(`Request method: ${method}`)
throw new Error(`Customer.io App API request failed: ${response.status} - ${errorText}`)
}
if (response.headers.get('content-type')?.includes('application/json')) {
return response.json()
}
return {} as T
}
async getCustomer(email: string): Promise<CustomerioCustomer | null> {
try {
const response = await this.makeRequest<{
results: CustomerioCustomer[]
}>(`/v1/customers?email=${encodeURIComponent(email)}`, 'GET')
// The API returns a results array, so we need to get the first customer
if (response.results && response.results.length > 0) {
return response.results[0]
}
return null
} catch (error) {
console.error('Customer.io customer lookup failed:', error)
// If customer not found, return null instead of throwing
if (error instanceof Error && error.message.includes('404')) {
return null
}
throw error
}
}
async getCustomerAttributes(email: string): Promise<Record<string, unknown> | null> {
const customer = await this.getCustomer(email)
if (!customer) {
console.error('No customer found for email:', email)
return null
}
if (!customer.id) {
console.error('Customer found but has no ID:', customer)
return null
}
console.log('Found customer with ID:', customer.id)
// Now get the specific attributes for this customer
try {
const attributes = await this.makeRequest<Record<string, unknown>>(
`/v1/customers/${customer.id}/attributes`,
'GET'
)
return attributes
} catch (error) {
console.error('Failed to fetch customer attributes:', error)
return null
}
}
async getCustomerSegments(email: string): Promise<CustomerioSegment[]> {
const customer = await this.getCustomer(email)
if (!customer || !customer.id) {
console.error('No customer found for email:', email)
return []
}
try {
const response = await this.makeRequest<{
segments: CustomerioSegment[]
}>(`/v1/customers/${customer.id}/segments`, 'GET')
return response.segments || []
} catch (error) {
console.error('Failed to fetch customer segments:', error)
return []
}
}
async sendTransactionalEmail(
request: TransactionalEmailRequest
): Promise<TransactionalEmailResponse> {
try {
const response = await this.makeRequest<TransactionalEmailResponse>(
'/v1/send/email',
'POST',
request
)
return response
} catch (error) {
console.error('Failed to send transactional email:', error)
throw error
}
}
isoToUnixTimestamp(isoString: string): number {
return Math.floor(new Date(isoString).getTime() / 1000)
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'server-only'
export const getTitlePropertyName = async (dbId: string, apiKey: string): Promise<string> => {
const resp = await fetch(`https://api.notion.com/v1/databases/${dbId}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Notion-Version': '2022-06-28',
},
})
if (!resp.ok) throw new Error('Failed to retrieve database metadata')
const db: any = await resp.json()
const entry = Object.entries(db.properties).find(([, v]: any) => v?.type === 'title')
if (!entry) throw new Error('No title property found in notion database')
return entry[0]
}
export const insertPageInDatabase = async (
dbId: string,
apiKey: string,
data: any
): Promise<string> => {
const resp = await fetch(`https://api.notion.com/v1/pages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({
parent: { database_id: dbId },
properties: data,
}),
})
if (!resp.ok) {
const respText = await resp.text()
throw new Error('Failed to insert page into notion database: ' + respText)
}
const json = await resp.json()
return json.id
}
@@ -1,97 +0,0 @@
import Layout from '~/layouts/Layout'
import SectionContainer from '~/components/Layouts/SectionContainer'
export const meta = {
title: 'SupaSquad | Supabase',
description: 'Supabase Advocate program',
}
<SectionContainer>
# SupaSquad
The SupaSquad is an official Supabase advocate program where community members help build and manage the Supabase community.
- Official recognition in the Supabase community.
- Direct connection to the Supabase team.
- Help steer the Supabase community.
![Supabase SupaSquad](/images/supabase-squad.png)
## Requirements
As a member of the Squad, you choose the approach where you'll provide the most value.
You can help in one of five ways:
### Maintainer
Help maintain Supabase repositories. This includes building client libraries, managing issues, and fixing bugs.
### Expert
Answer user questions on GitHub [Discussions](https://github.com/supabase/supabase/discussions), [Discord](https://discord.supabase.com), and various other social platforms.
### Advocate
Spread the word on social channels and help to answer Supabase-related questions in the broader community and social channels.
### Builder
Build Supabase examples, blog about them, and add them to the [Supabase repo](https://github.com/supabase/supabase/tree/master/examples).
### Author
Write guest blog posts, create documentation, and help Supabase global expansion through translation.
### Moderator
Help us maintain the community guidelines in our GitHub and Community-led communities such as [Discord](https://discord.supabase.com), [Reddit](https://reddit.com/r/Supabase/),
[StackOverflow](https://stackoverflow.com/questions/tagged/supabase), etc.
## Benefits for SupaSquad members
- Access to a Supabase Discord channel providing direct communication with the team, Discord badges, and elevated privileges.
- Special AMA sessions with members of the Supabase team.
- Monthly DevRel committee call with industry-leading Developer Advocates (many of whom are [angel investors](https://supabase.com/blog/angels-of-supabase)), where you can learn from the best.
- We'll help you build your audience by promoting content via the Supabase social channels.
- Featured profile on Supabase website.
- Early access to new features (and the opportunity to provide feedback to the team!).
- Free credits that you can use for Squad efforts.
- Direct access to members of the Supabase team for questions, suggestions, etc.
- Help shape the future of the program.
- Exclusive Supabase Team swag drops are usually exclusively reserved for the Supabase core team.
## How to join
Apply to join the program using [this form](https://airtable.com/shr0FtLqLfhpuEya8).
## FAQs
<details>
<summary>
<i>Why are you only admitting 20 new members?</i>
</summary>
The entire Supabase team is only 20 people, so as you can imagine adding another 20 people sounds like
a lot to us! We wish we could admit everyone who wanted to join. But we also want to make sure everyone
who joins the Squad has an awesome experience. In the future we will probably expand the intake to
include a monthly quota.
</details>
<details>
<summary>
<i>What is expected?</i>
</summary>
Mostly just enthusiasm. If you are interested in Open Source and want to get involved, the SupaSquad
program is a great channel. You'll be given opportunities to contribute to the community in whatever
ways match your skillset.
</details>
<details>
<summary>
<i>What if I become too busy to contribute?</i>
</summary>
No worries! The program isn't a job. It's just an opportunity to build your skillset and audience within
the Supabase ecosystem.
</details>
</SectionContainer>
export default (context) => <Layout meta={meta} children={context.children} context={context} />
@@ -0,0 +1,53 @@
import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo'
import Layout from 'components/Layouts/Default'
import ProductHeader from 'components/Sections/ProductHeader2'
import { data as content } from 'data/open-source/contributing/supasquad'
const Quotes = dynamic(() => import('components/Supasquad/Quotes'))
const WhySupaSquad = dynamic(() => import('components/Supasquad/FeaturesSection'))
const PerfectTiming = dynamic(() => import('components/Supasquad/PerfectTiming'))
const Benefits = dynamic(() => import('components/Supasquad/FeaturesSection'))
const ApplicationFormSection = dynamic(() => import('components/Supasquad/ApplicationFormSection'))
const BeginnersPage: NextPage = () => {
return (
<>
<NextSeo
title={content.metadata.metaTitle}
description={content.metadata.metaDescription}
openGraph={{
title: content.metadata.metaTitle,
description: content.metadata.metaDescription,
url: `https://supabase.com/solutions/beginners`,
}}
/>
<Layout className="overflow-visible">
<ProductHeader
{...content.heroSection}
className="[&_h1]:2xl:!text-5xl bg-default border-0 lg:pb-8 [&_.ph-footer]:mt-0 [&_.ph-footer]:lg:mt-16 [&_.ph-footer]:xl:mt-32"
sectionContainerClassName="lg:gap-4"
/>
{/* <Quotes {...content.quotes} /> */}
<WhySupaSquad {...content.why} />
<PerfectTiming
id={content.timing.id}
heading={content.timing.heading}
subheading={content.timing.subheading}
highlights={content.timing.highlights}
/>
<Benefits className="md:mt-24" {...content.benefits} />
<ApplicationFormSection
id={content.ctaSection.id}
title={content.ctaSection.title}
cta={content.ctaSection.primaryCta}
/>
</Layout>
</>
)
}
export default BeginnersPage
+14 -14
View File
@@ -294,20 +294,6 @@
<description>Technical deep dive into the new DBOS integration for Supabase</description>
<pubDate>Tue, 10 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/hack-the-base</guid>
<title>Hack the Base! with Supabase</title>
<link>https://supabase.com/blog/hack-the-base</link>
<description>Play cool games, win cool prizes.</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/launch-week-13-top-10</guid>
<title>Top 10 Launches of Launch Week 13</title>
<link>https://supabase.com/blog/launch-week-13-top-10</link>
<description>Highlights from Launch Week 13</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/database-build-v2</guid>
<title>database.build v2: Bring-your-own-LLM</title>
@@ -322,6 +308,20 @@
<description>Effortlessly Clone Data into a New Supabase Project</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/hack-the-base</guid>
<title>Hack the Base! with Supabase</title>
<link>https://supabase.com/blog/hack-the-base</link>
<description>Play cool games, win cool prizes.</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/launch-week-13-top-10</guid>
<title>Top 10 Launches of Launch Week 13</title>
<link>https://supabase.com/blog/launch-week-13-top-10</link>
<description>Highlights from Launch Week 13</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/supabase-queues</guid>
<title>Supabase Queues</title>
+8 -1
View File
@@ -165,7 +165,14 @@
"FORCE_ASSET_CDN",
"ASSET_CDN_S3_ENDPOINT",
"SITE_NAME",
"LUMA_API_KEY"
"LUMA_API_KEY",
// These env vars are used by some community and marketing integrations
"SENTRY_DSN_COMMUNITY",
"NOTION_SUPASQUAD_API_KEY",
"NOTION_SUPASQUAD_APPLICATIONS_DB_ID",
"CUSTOMERIO_SITE_ID",
"CUSTOMERIO_API_KEY",
"CUSTOMERIO_APP_API_KEY"
],
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
},