From 4f6e65a6f557f34f623c199428b4df0ecd47e648 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Tue, 3 Jun 2025 15:42:43 +0200 Subject: [PATCH] state of startups 2025 (#36047) * state of startups page init * state of startups page design * finish dark aware design * embed placeholder typescript form * embed typeform in popup * newsletter form to hubspot * update typeform id and increase circle size on mobile * update meta description * promo * fix line height and yc * update subheading * fix conflict and error handling * fix anime js --- .../submit-form-sos2025-newsletter/route.tsx | 75 +++ apps/www/components/Hero/Hero.tsx | 6 + apps/www/components/Nav/index.tsx | 4 +- .../Products/Functions/FunctionsHero.tsx | 4 +- .../data/surveys/state-of-startups-2025.tsx | 25 + apps/www/package.json | 2 +- apps/www/pages/state-of-startups.tsx | 534 ++++++++++++++++++ packages/common/telemetry-constants.ts | 18 + .../Banners/AnnouncementBanner.tsx | 10 + packages/ui-patterns/Banners/SOSBanner.tsx | 30 + packages/ui-patterns/Banners/index.ts | 1 + packages/ui-patterns/PopupFrame/index.tsx | 90 +++ packages/ui-patterns/index.tsx | 2 + pnpm-lock.yaml | 12 +- 14 files changed, 803 insertions(+), 10 deletions(-) create mode 100644 apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx create mode 100644 apps/www/data/surveys/state-of-startups-2025.tsx create mode 100644 apps/www/pages/state-of-startups.tsx create mode 100644 packages/ui-patterns/Banners/AnnouncementBanner.tsx create mode 100644 packages/ui-patterns/Banners/SOSBanner.tsx create mode 100644 packages/ui-patterns/Banners/index.ts create mode 100644 packages/ui-patterns/PopupFrame/index.tsx diff --git a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx new file mode 100644 index 0000000000..856845ad62 --- /dev/null +++ b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx @@ -0,0 +1,75 @@ +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +const isValidEmail = (email: string): boolean => { + const emailPattern = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/ + return emailPattern.test(email) +} + +export async function POST(req: Request) { + const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID + const HUBSPOT_FORM_GUID = '721fc4aa-13eb-4c25-91be-4fe9b530bed1' + + const body = await req.json() + const { email } = body + + if (!email) { + return new Response(JSON.stringify({ message: 'All fields are required' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 422, + }) + } + + // Validate email + if (email && !isValidEmail(email)) { + return new Response(JSON.stringify({ message: 'Invalid email address' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 422, + }) + } + + try { + const response = await fetch( + `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_GUID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: [{ objectTypeId: '0-1', name: 'email', value: email }], + context: { + pageUri: 'https://supabase.com/state-of-startups', + pageName: 'State of Startups 2025', + }, + legalConsentOptions: { + consent: { + consentToProcess: true, + text: 'By submitting this form, I confirm that I have read and understood the Privacy Policy.', + }, + }, + }), + } + ) + + if (!response.ok) { + const errorData = await response.json() + return new Response(JSON.stringify({ message: errorData.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: response.status, + }) + } + + return new Response(JSON.stringify({ message: 'Submission successful' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }) + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }) + } +} diff --git a/apps/www/components/Hero/Hero.tsx b/apps/www/components/Hero/Hero.tsx index 303015cfa2..18dd713e64 100644 --- a/apps/www/components/Hero/Hero.tsx +++ b/apps/www/components/Hero/Hero.tsx @@ -16,6 +16,12 @@ const Hero = () => {
+

Build in a weekend Scale to millions diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index a1f59fb792..acf6ac95b1 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -24,7 +24,8 @@ import MobileMenu from './MobileMenu' import RightClickBrandLogo from './RightClickBrandLogo' import { useSendTelemetryEvent } from '~/lib/telemetry' import useDropdownMenu from './useDropdownMenu' -import { AuthenticatedDropdownMenu } from 'ui-patterns' +import { AnnouncementBanner, AuthenticatedDropdownMenu } from 'ui-patterns' +import Announcement from '../LaunchWeek/7/LaunchSection/Announcement' interface Props { hideNavbar: boolean @@ -71,6 +72,7 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => { return ( <> +
{ }, []) const animate = () => { - const tl = anime.timeline({ + const tl = createTimeline({ loop: false, autoplay: true, }) diff --git a/apps/www/data/surveys/state-of-startups-2025.tsx b/apps/www/data/surveys/state-of-startups-2025.tsx new file mode 100644 index 0000000000..b42939a85b --- /dev/null +++ b/apps/www/data/surveys/state-of-startups-2025.tsx @@ -0,0 +1,25 @@ +export default (isMobile?: boolean) => ({ + metaTitle: 'State of Startups 2025', + metaDescription: + 'Take the survey and learn the latest trends among builders in tech stacks, AI usage, problem domains, and more.', + metaImage: '/images/modules/vector/og.png', + docsUrl: '', + heroSection: { + title: 'State of Startups 2025', + h1: ( + <> + The Postgres Vector database
+ and AI Toolkit + + ), + subheader: ( + <> + There's never been a better time to build. +
+ Take our State of Startups survey to receive an exclusive Supabase t-shirt and a report on + the survey results. + + ), + className: '[&_h1]:max-w-2xl', + }, +}) diff --git a/apps/www/package.json b/apps/www/package.json index db6989e702..17d34c66b3 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -33,7 +33,7 @@ "@supabase/supabase-js": "catalog:", "@vercel/og": "^0.6.2", "ai-commands": "workspace:*", - "animejs": "^3.2.2", + "animejs": "^4.0.2", "class-variance-authority": "^0.7.1", "classnames": "^2.3.1", "clsx": "^1.2.1", diff --git a/apps/www/pages/state-of-startups.tsx b/apps/www/pages/state-of-startups.tsx new file mode 100644 index 0000000000..78f266a023 --- /dev/null +++ b/apps/www/pages/state-of-startups.tsx @@ -0,0 +1,534 @@ +import { useEffect, useRef, useState } from 'react' +import { animate, createSpring, createTimeline, stagger } from 'animejs' +import Link from 'next/link' +import Image from 'next/image' +import { NextSeo } from 'next-seo' + +import { Button, Checkbox, cn } from 'ui' +import { PopupFrame } from 'ui-patterns' +import { Input } from 'ui/src/components/shadcn/ui/input' +import { Label } from 'ui/src/components/shadcn/ui/label' +import DefaultLayout from '~/components/Layouts/Default' +import SectionContainer from '~/components/Layouts/SectionContainer' +import { useSendTelemetryEvent } from '~/lib/telemetry' + +import data from '~/data/surveys/state-of-startups-2025' + +interface FormData { + email: string + terms: boolean +} + +interface FormItem { + type: 'email' | 'checkbox' + label: string + placeholder: string + required: boolean + className?: string + component: typeof Input | typeof Checkbox +} + +type FormConfig = { + [K in keyof FormData]: FormItem +} + +const formConfig: FormConfig = { + email: { + type: 'email', + label: 'Email', + placeholder: 'Email', + required: true, + className: '', + component: Input, + }, + terms: { + type: 'checkbox', + label: '', + placeholder: '', + required: true, + className: '', + component: Checkbox, + }, +} + +const defaultFormValue: FormData = { + email: '', + terms: false, +} + +const isValidEmail = (email: string): boolean => { + const emailPattern = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/ + return emailPattern.test(email) +} + +function StateOfStartupsPage() { + const pageData = data() + const meta_title = pageData.metaTitle + const meta_description = pageData.metaDescription + const meta_image = pageData.metaImage + + const [formData, setFormData] = useState(defaultFormValue) + const [honeypot, setHoneypot] = useState('') // field to prevent spam + const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [success, setSuccess] = useState(null) + const [startTime, setStartTime] = useState(0) + + const sendTelemetryEvent = useSendTelemetryEvent() + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setErrors({}) + setFormData((prev) => ({ ...prev, [name]: value })) + } + + const handleReset = (e: React.MouseEvent) => { + e.preventDefault() + setFormData(defaultFormValue) + setSuccess(null) + setErrors({}) + } + + const validate = (): boolean => { + const newErrors: { [key in keyof FormData]?: string } = {} + + // Check required fields + for (const key in formConfig) { + if (formConfig[key as keyof FormData].required && !formData[key as keyof FormData]) { + if (key === 'email') { + newErrors[key as keyof FormData] = `Email is required` + } else if (key === 'terms') { + newErrors[key as keyof FormData] = `You must agree to the terms` + } + } + } + + // Validate email + if (formData.email && !isValidEmail(formData.email)) { + newErrors.email = 'Invalid email address' + } + + setErrors(newErrors) + + // Return validation status, also check if honeypot is filled (indicating a bot) + return Object.keys(newErrors).length === 0 && honeypot === '' + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + 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 + } + + if (!validate()) { + return + } + + setIsSubmitting(true) + setSuccess(null) + + try { + const response = await fetch('/api-v2/submit-form-sos2025-newsletter', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }) + + if (response.ok) { + setSuccess('Thank you for your submission!') + setFormData({ email: '', terms: false }) + } 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 ( + <> + + + + +
+

Stay in touch

+

+ Sign up for our newsletter to be notified when the survey results are available. +

+
+ {success ? ( +
+

{success}

+ +
+ ) : ( +
+
+ {/* Spam prevention */} + ) => + setHoneypot(e.target.value) + } + style={{ display: 'none' }} + aria-hidden="true" + /> + + +
+
+ + +
+
+ {errors['email'] ? errors['email'] : errors['terms']} +
+
+ )} +
+
+
+
+ + ) +} + +const Hero = (props: any) => { + const animRef = useRef(null) + + useEffect(() => { + if (!animRef.current) return + + const strings = [ + "What's your tech stack?", + "What's your favorite AI developer tool?", + 'Which vector database are you using?', + 'Are you building AI Agents?', + 'Do you use OpenTelemetry?', + 'Where do you go to learn?', + ] + + let currentIndex = 0 + + const animateText = () => { + const animatedText = animRef.current?.querySelector('#anim') + if (!animatedText) return + + const currentText = strings[currentIndex] + + animatedText.textContent = currentText + // Split by words and wrap each word, then wrap letters within each word + animatedText.innerHTML = currentText + .split(' ') + .map((word) => { + if (word.trim() === '') return ' ' + const wrappedLetters = word.replace( + /\S/g, + "$&" + ) + return `${wrappedLetters}` + }) + .join(' ') + + createTimeline({ + onComplete: () => { + currentIndex = (currentIndex + 1) % strings.length + setTimeout(() => { + animateOut() + }, 100) + }, + }).add(animatedText.querySelectorAll('.letter'), { + opacity: [0, 1], + translateY: [-8, 0], + ease: createSpring({ stiffness: 150, damping: 15 }), + duration: 500, + delay: stagger(10), + }) + } + + const animateOut = () => { + const animatedText = animRef.current?.querySelector('#anim') + if (!animatedText) return + + animate(animatedText.querySelectorAll('.letter'), { + opacity: [1, 0], + translateY: [0, 8], + ease: 'inExpo', + duration: 500, + delay: stagger(10), + onComplete: () => { + setTimeout(animateText, -100) + }, + }) + } + + animateText() + + return () => {} + }, []) + + return ( + <> +
+
+
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+
+
+
+ Supabase icon +
+
+
+ {props.icon || props.title ? ( +
+ {props.title && ( +

+ {props.title} +

+ )} +
+ ) : null} +
+
+
+
+ State of Startups +
+
+

{props.subheader}

+
+
+ + Take the survey + + } + className="[&_.modal-content]:min-h-[650px] [&_.modal-content]:!h-[75vh] [&_.modal-content]:flex [&_.modal-content]:flex-col" + > +
+