diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx index 9d780a46be96f..ebb1fd4158f1e 100644 --- a/apps/docs/components/GuidesTableOfContents.tsx +++ b/apps/docs/components/GuidesTableOfContents.tsx @@ -1,6 +1,7 @@ 'use client' import { usePathname } from 'next/navigation' +import { isFeatureEnabled } from 'common' import { cn } from 'ui' import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc' @@ -18,6 +19,8 @@ const GuidesTableOfContents = ({ className, video }: { className?: string; video const pathname = usePathname() const { toc } = useTocAnchors() + const showFeedback = isFeatureEnabled('feedback:docs') + const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg` return ( @@ -28,9 +31,11 @@ const GuidesTableOfContents = ({ className, video }: { className?: string; video )} -
- -
+ {showFeedback && ( +
+ +
+ )} {toc.length !== 0 && (

diff --git a/apps/www/app/api-v2/submit-form-apply-to-supasquad/route.tsx b/apps/www/app/api-v2/submit-form-apply-to-supasquad/route.tsx new file mode 100644 index 0000000000000..9de5a34550f74 --- /dev/null +++ b/apps/www/app/api-v2/submit-form-apply-to-supasquad/route.tsx @@ -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 = { + 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 +} diff --git a/apps/www/components/Forms/ApplyToSupaSquadForm.tsx b/apps/www/components/Forms/ApplyToSupaSquadForm.tsx new file mode 100644 index 0000000000000..017d247283e57 --- /dev/null +++ b/apps/www/components/Forms/ApplyToSupaSquadForm.tsx @@ -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 + +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> + errors: { [key: string]: string } + onSubmit: (data: ApplicationFormData) => Promise + honeypot: string + setHoneypot: React.Dispatch> + isMobile: boolean + isSubmitted: boolean + handleCancel: () => void + isSubmitting: boolean +}) { + return ( +
+ +
+
+ ( + + First Name * + + + + + + + )} + /> + + ( + + Last Name * + +
+ +
+
+ + +
+ )} + /> +
+ + ( + + Email Address * + +
+ +
+
+ +
+ )} + /> + + + +
+

Interests and skills

+ ( + + + Why do you want to join the program? * + + + What do you have to contribute? What would you like to get out of it? + + +
+ +
+
+ +
+ )} + /> + + ( + + + What track would you like to be considered for? * + + + See longer descriptions of the 4 options above + + +
+ { + // 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" + > + + + + {tracks.map((item) => ( + +
+
{item.heading}
+
+ {item.description} +
+
+
+ ))} +
+
+
+
+
+ + +
+ )} + /> + + ( + + + Product Areas of Interest * + + + What specific areas would you like to help with? Leave blank if you're not sure. + + +
+ + + + + {productAreasOfInterest.map((item) => ( + + {item} + + ))} + + + +
+
+ +
+ )} + /> + + ( + + + Skills (frameworks, tools, programming languages) + + + Know Postgres really well? React? Python? Rust? Terraform? Add it here! + + +
+ +
+
+ +
+ )} + /> +
+ + + +
+

Location and Availability

+ +
+ ( + + Country * + +
+ {/* */} + +
+
+ +
+ )} + /> + + ( + + City * + +
+ +
+
+ +
+ )} + /> +
+ + ( + + + Monthly Commitment + + + How many hours can you commit per month? If not sure, leave blank. + + +
+ +
+
+ +
+ )} + /> + + ( + + + Languages spoken * + + + What languages do you speak? + + +
+ + + + + {languagesSpoken.map((item) => ( + + {item} + + ))} + + + +
+
+ +
+ )} + /> +
+ + + +
+

Social Links

+ +
+ ( + + GitHub + +
+ +
+
+ +
+ )} + /> + + ( + + Twitter + +
+ +
+
+ +
+ )} + /> +
+
+ + {!isSubmitted && ( +
+ + +
+ )} + + {/* Spam prevention */} + ) => setHoneypot(e.target.value)} + className="hidden" + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + /> + +
+ + {Object.values(errors).length > 0 && ( + + + {Object.values(errors).join('\n')} + + )} +
+ ) +}) + +const ApplyToSupaSquadForm: FC = ({ className }) => { + const [honeypot, setHoneypot] = useState('') // 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(null) + const [showConfirmation, setShowConfirmation] = useState(false) + const [startTime, setStartTime] = useState(0) + + const form = useForm({ + 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 ( + <> +
+
+

{headerContent.title}

+

{headerContent.description}

+
+ + + + +
+ + {/* Confirmation AlertDialog Overlay */} + {}}> + + Application Submitted + + Your application has been successfully submitted. Please check your email for + confirmation. + +
+
+ +
+
+

Application Submitted!

+

+ Thank you for your submission. Please check your email for a confirmation link to + complete your application. +

+
+ + Got it, thanks! + +
+
+
+ + ) +} + +export default ApplyToSupaSquadForm diff --git a/apps/www/components/Solutions/FeaturesSection.tsx b/apps/www/components/Solutions/FeaturesSection.tsx index 4f99a5beb5bb6..551d960adc7da 100644 --- a/apps/www/components/Solutions/FeaturesSection.tsx +++ b/apps/www/components/Solutions/FeaturesSection.tsx @@ -63,7 +63,7 @@ const FeatureItem: FC = ({ feature }) => { /> ) : ( - + ))}
diff --git a/apps/www/components/Supasquad/ApplicationFormSection.tsx b/apps/www/components/Supasquad/ApplicationFormSection.tsx new file mode 100644 index 0000000000000..c8103d6b9adb6 --- /dev/null +++ b/apps/www/components/Supasquad/ApplicationFormSection.tsx @@ -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 ( + +
+
+

{title}

+ {subtitle &&

{subtitle}

} + +
+
+ +
+
+ ) +} + +export default ApplicationFormSection diff --git a/apps/www/components/Supasquad/CountrySelector.tsx b/apps/www/components/Supasquad/CountrySelector.tsx new file mode 100644 index 0000000000000..b415cfd461536 --- /dev/null +++ b/apps/www/components/Supasquad/CountrySelector.tsx @@ -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 ( + + ) +} diff --git a/apps/www/components/Supasquad/CtaSection.tsx b/apps/www/components/Supasquad/CtaSection.tsx new file mode 100644 index 0000000000000..a73aa95a00682 --- /dev/null +++ b/apps/www/components/Supasquad/CtaSection.tsx @@ -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 ( + +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ + {secondaryCta && ( + + )} +
+
+
+ +
+
+ ) +} + +export default CtaSection diff --git a/apps/www/components/Supasquad/FeatureIcon.tsx b/apps/www/components/Supasquad/FeatureIcon.tsx new file mode 100644 index 0000000000000..a312031c38ee5 --- /dev/null +++ b/apps/www/components/Supasquad/FeatureIcon.tsx @@ -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 = ({ 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' ? ( + + + + ) : ( + + )) + ) +} + +export default FeatureIcon diff --git a/apps/www/components/Supasquad/FeaturesSection.tsx b/apps/www/components/Supasquad/FeaturesSection.tsx new file mode 100644 index 0000000000000..bc2cc753190e7 --- /dev/null +++ b/apps/www/components/Supasquad/FeaturesSection.tsx @@ -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 ( + +
+ {props.label} +

{props.heading}

+ {props.subheading &&

{props.subheading}

} +
+
    + {props.features?.map((feature: Feature, index: number) => ( + + ))} +
+
+ ) +} + +interface FeatureItemProps { + feature: Feature +} + +const FeatureItem: FC = ({ feature }) => { + return ( +
  • + +
    + +
    +

    {feature.heading}

    +

    {feature.subheading}

    + {/* */} +
  • + ) +} + +export default FeaturesSection diff --git a/apps/www/components/Supasquad/PerfectTiming.tsx b/apps/www/components/Supasquad/PerfectTiming.tsx new file mode 100644 index 0000000000000..beea6adf4da67 --- /dev/null +++ b/apps/www/components/Supasquad/PerfectTiming.tsx @@ -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 ( + +
    +
    + {/* {props.label} */} +

    {props.heading}

    +

    {props.subheading}

    +
    +
    + {props.highlights.map((highlight) => ( + + ))} +
    +
    +
    + + + + + + + + + + + +
    +
    + + ) +} + +const GraphLabel: FC<{ className?: string }> = ({ className }) => ( +
    +
    + Users +
    + 230,550 + + +13.4% + +
    +
    +
    +
    +) + +interface HighlightItemProps { + highlight: Highlight +} + +const HighlightItem: FC = ({ highlight }) => { + return ( +
  • + {highlight.heading} +

    {highlight.subheading}

    +
  • + ) +} + +export default PerfectTiming diff --git a/apps/www/components/Supasquad/Quotes.tsx b/apps/www/components/Supasquad/Quotes.tsx new file mode 100644 index 0000000000000..a278ca2971c0a --- /dev/null +++ b/apps/www/components/Supasquad/Quotes.tsx @@ -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 = (props) => ( +
    +
    + +
      + {props.items?.map((quote: Quote) => ( +
    • + +
    • + ))} +
    +
    + + {props.items?.map((quote: Quote, i: number) => ( + + + + ))} + +
    +
    +
    +
    +) + +const QuoteCard: FC = ({ quote, author, avatar, authorTitle }) => { + return ( + +
    + {quote} +
    + +
    + {author} +
    + {author} + {authorTitle && ( + + {authorTitle} + + )} +
    +
    +
    + ) +} + +export default Quotes diff --git a/apps/www/data/open-source/contributing/supasquad.tsx b/apps/www/data/open-source/contributing/supasquad.tsx new file mode 100644 index 0000000000000..78e26cf3e5987 --- /dev/null +++ b/apps/www/data/open-source/contributing/supasquad.tsx @@ -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: ( + Supabase for Beginners + ), + 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.{' '} + The community is so supportive, 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.{' '} + + It's amazing to see developers discover how much faster they can build + {' '} + 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. + + Working directly with the core team on real production code + {' '} + has given me insights I never would have gained otherwise. + + ), + }, + ], + }, + why: { + id: 'why-supasquad', + label: '', + heading: ( + <> + Contribute in the way that best fits your{' '} + unique skills + + ), + 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: Benefits for our members, + 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, + }, + }, +} diff --git a/apps/www/data/open-source/contributing/supasquad.utils.tsx b/apps/www/data/open-source/contributing/supasquad.utils.tsx new file mode 100644 index 0000000000000..a895fb03f9e6f --- /dev/null +++ b/apps/www/data/open-source/contributing/supasquad.utils.tsx @@ -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> +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 ( + +
    + {typeof framework.icon === 'string' ? ( + + + + ) : ( + framework.icon + )} +
    + + {framework.name} + + + ) +} + +export const getEditors: (isXs: boolean) => FrameworkLinkProps[] = (isXs) => [ + { + name: 'Cursor', + icon: ( + + + + + + + + + + + + + + + + + + + ), + 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', + }, +] diff --git a/apps/www/lib/customerio.ts b/apps/www/lib/customerio.ts new file mode 100644 index 0000000000000..0c0bbe47a3e79 --- /dev/null +++ b/apps/www/lib/customerio.ts @@ -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 + timestamp: number +} + +interface CustomerioCustomer { + id: string + email: string + attributes: Record + 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 + 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( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + body?: unknown + ): Promise { + 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 = {} + ): Promise { + const profile = { + email, + ...attributes, + } + + await this.makeRequest(`/customers/${encodeURIComponent(email)}`, 'PUT', profile) + } + + async trackEvent(email: string, event: CustomerioEvent): Promise { + 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( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + body?: unknown + ): Promise { + 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 { + 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 | 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>( + `/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 { + 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 { + try { + const response = await this.makeRequest( + '/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) + } +} diff --git a/apps/www/lib/notion.ts b/apps/www/lib/notion.ts new file mode 100644 index 0000000000000..332bb15c0edbb --- /dev/null +++ b/apps/www/lib/notion.ts @@ -0,0 +1,41 @@ +import 'server-only' + +export const getTitlePropertyName = async (dbId: string, apiKey: string): Promise => { + 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 => { + 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 +} diff --git a/apps/www/pages/open-source/contributing/supasquad.mdx b/apps/www/pages/open-source/contributing/supasquad.mdx deleted file mode 100644 index 99451bcfdeefd..0000000000000 --- a/apps/www/pages/open-source/contributing/supasquad.mdx +++ /dev/null @@ -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', -} - - - -# 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 - -
    - - Why are you only admitting 20 new members? - - 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. -
    -
    - - What is expected? - - 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. -
    -
    - - What if I become too busy to contribute? - - No worries! The program isn't a job. It's just an opportunity to build your skillset and audience within - the Supabase ecosystem. -
    - -
    - -export default (context) => diff --git a/apps/www/pages/open-source/contributing/supasquad.tsx b/apps/www/pages/open-source/contributing/supasquad.tsx new file mode 100644 index 0000000000000..fd1da161f31f1 --- /dev/null +++ b/apps/www/pages/open-source/contributing/supasquad.tsx @@ -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 ( + <> + + + + {/* */} + + + + + + + ) +} + +export default BeginnersPage diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 5d94c59ef2dc1..7024e325fe81f 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -294,20 +294,6 @@ Technical deep dive into the new DBOS integration for Supabase Tue, 10 Dec 2024 00:00:00 -0700 - - https://supabase.com/blog/hack-the-base - Hack the Base! with Supabase - https://supabase.com/blog/hack-the-base - Play cool games, win cool prizes. - Fri, 06 Dec 2024 00:00:00 -0700 - - - https://supabase.com/blog/launch-week-13-top-10 - Top 10 Launches of Launch Week 13 - https://supabase.com/blog/launch-week-13-top-10 - Highlights from Launch Week 13 - Fri, 06 Dec 2024 00:00:00 -0700 - https://supabase.com/blog/database-build-v2 database.build v2: Bring-your-own-LLM @@ -322,6 +308,20 @@ Effortlessly Clone Data into a New Supabase Project Fri, 06 Dec 2024 00:00:00 -0700 + + https://supabase.com/blog/hack-the-base + Hack the Base! with Supabase + https://supabase.com/blog/hack-the-base + Play cool games, win cool prizes. + Fri, 06 Dec 2024 00:00:00 -0700 + + + https://supabase.com/blog/launch-week-13-top-10 + Top 10 Launches of Launch Week 13 + https://supabase.com/blog/launch-week-13-top-10 + Highlights from Launch Week 13 + Fri, 06 Dec 2024 00:00:00 -0700 + https://supabase.com/blog/supabase-queues Supabase Queues diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 58068090300d3..7c7ee99b442e0 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -29,6 +29,8 @@ "docs:self-hosting": true, + "feedback:docs": true, + "integrations:vercel": true, "integrations:show_stripe_wrapper": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index ca38b65741655..ae6eeb10c655c 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -100,6 +100,11 @@ "description": "Enable documentation for self-hosting" }, + "feedback:docs": { + "type": "boolean", + "description": "Enable feedback submission for docs site" + }, + "integrations:vercel": { "type": "boolean", "description": "Enable the vercel integration section in the organization and project settings pages" @@ -233,6 +238,7 @@ "database:replication", "database:roles", "docs:self-hosting", + "feedback:docs", "integrations:vercel", "integrations:show_stripe_wrapper", "profile:show_email", diff --git a/turbo.json b/turbo.json index abec773d5910f..b1b8c12160fe2 100644 --- a/turbo.json +++ b/turbo.json @@ -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/**"] },