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
This commit is contained in:
committed by
GitHub
parent
6ac53686cb
commit
4f6e65a6f5
75
apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx
Normal file
75
apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ const Hero = () => {
|
||||
<div className="mx-auto max-w-2xl lg:col-span-6 lg:flex lg:items-center justify-center text-center">
|
||||
<div className="relative z-10 lg:h-auto pt-[90px] lg:pt-[90px] lg:min-h-[300px] flex flex-col items-center justify-center sm:mx-auto md:w-3/4 lg:mx-0 lg:w-full gap-4 lg:gap-8">
|
||||
<div className="flex flex-col items-center">
|
||||
<AnnouncementBadge
|
||||
url="/state-of-startups"
|
||||
announcement="Take the survey"
|
||||
badge="State of Startups 2025"
|
||||
className="mb-8 -mt-4 lg:-mt-8"
|
||||
/>
|
||||
<h1 className="text-foreground text-4xl sm:text-5xl sm:leading-none lg:text-7xl">
|
||||
<span className="block text-foreground">Build in a weekend</span>
|
||||
<span className="text-brand block md:ml-0">Scale to millions</span>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<AnnouncementBanner />
|
||||
<div
|
||||
className={cn('sticky top-0 z-40 transform', disableStickyNav && 'relative')}
|
||||
style={{ transform: 'translate3d(0,0,999px)' }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import anime from 'animejs'
|
||||
import { createTimeline } from 'animejs'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import Typed from 'typed.js'
|
||||
@@ -127,7 +127,7 @@ const FunctionsHero = () => {
|
||||
}, [])
|
||||
|
||||
const animate = () => {
|
||||
const tl = anime.timeline({
|
||||
const tl = createTimeline({
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
})
|
||||
|
||||
25
apps/www/data/surveys/state-of-startups-2025.tsx
Normal file
25
apps/www/data/surveys/state-of-startups-2025.tsx
Normal file
@@ -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 <br className="hidden md:block" />
|
||||
and AI Toolkit
|
||||
</>
|
||||
),
|
||||
subheader: (
|
||||
<>
|
||||
There's never been a better time to build.
|
||||
<br />
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
534
apps/www/pages/state-of-startups.tsx
Normal file
534
apps/www/pages/state-of-startups.tsx
Normal file
@@ -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<FormData>(defaultFormValue)
|
||||
const [honeypot, setHoneypot] = useState<string>('') // field to prevent spam
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [startTime, setStartTime] = useState<number>(0)
|
||||
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setErrors({})
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleReset = (e: React.MouseEvent<HTMLButtonElement, 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 (
|
||||
<>
|
||||
<NextSeo
|
||||
title={meta_title}
|
||||
description={meta_description}
|
||||
openGraph={{
|
||||
title: meta_title,
|
||||
description: meta_description,
|
||||
url: `https://supabase.com/modules/vector`,
|
||||
images: [
|
||||
{
|
||||
url: meta_image,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<DefaultLayout className="!bg-alternative overflow-hidden sm:!overflow-visible">
|
||||
<Hero {...pageData.heroSection} />
|
||||
<SectionContainer>
|
||||
<div className="flex flex-col text-center gap-4 py-8 items-center justify-center">
|
||||
<h2 className="heading-gradient text-2xl sm:text-3xl xl:text-4xl">Stay in touch</h2>
|
||||
<p className="mx-auto text-foreground-lighter w-full">
|
||||
Sign up for our newsletter to be notified when the survey results are available.
|
||||
</p>
|
||||
<div className="w-full mt-4 flex items-center justify-center text-center gap-4">
|
||||
{success ? (
|
||||
<div className="flex flex-col h-full w-full min-w-[300px] gap-4 items-center justify-center opacity-0 transition-opacity animate-fade-in scale-1">
|
||||
<p className="text-center text-sm">{success}</p>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
noValidate
|
||||
id="state-of-startups-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md flex flex-col gap-4 items-center"
|
||||
>
|
||||
<div className="w-full flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
{/* Spam prevention */}
|
||||
<input
|
||||
type="text"
|
||||
name="honeypot"
|
||||
value={honeypot}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setHoneypot(e.target.value)
|
||||
}
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
required
|
||||
onChange={handleChange}
|
||||
value={formData['email']}
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={isSubmitting}
|
||||
size="small"
|
||||
onClick={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'register_for_state_of_startups_newsletter_clicked',
|
||||
properties: { buttonLocation: 'State of Startups 2025 Newsletter Form' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-left">
|
||||
<Checkbox
|
||||
required
|
||||
name="terms"
|
||||
id="terms"
|
||||
onChange={handleChange}
|
||||
className="[&>input]:m-0"
|
||||
/>
|
||||
<Label htmlFor="terms" className="text-foreground-lighter leading-5">
|
||||
We process your information in accordance with our{' '}
|
||||
<Link href="/privacy" className="text-foreground-light hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-nowrap text-right gap-1 items-center text-xs leading-none transition-opacity opacity-0 text-foreground-lighter',
|
||||
errors['email'] && 'opacity-100 animate-fade-in',
|
||||
errors['terms'] && 'opacity-100 animate-fade-in'
|
||||
)}
|
||||
>
|
||||
{errors['email'] ? errors['email'] : errors['terms']}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Hero = (props: any) => {
|
||||
const animRef = useRef<HTMLDivElement | null>(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,
|
||||
"<span class='letter' style='opacity: 0; transform: translateY(-6px); display: inline-block;'>$&</span>"
|
||||
)
|
||||
return `<span class="word" style="display: inline-block; white-space: nowrap;">${wrappedLetters}</span>`
|
||||
})
|
||||
.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 (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'container relative w-full mx-auto px-6 py-8 md:py-16 sm:px-16 xl:px-20',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={animRef}
|
||||
className="flex flex-col text-center items-center justify-center min-h-[70vh]"
|
||||
>
|
||||
<div className="absolute overflow-hidden -mx-[15vw] sm:mx-0 inset-0 w-[calc(100%+30vw)] sm:w-full h-full col-span-12 lg:col-span-7 xl:col-span-6 xl:col-start-7 flex justify-center">
|
||||
<svg
|
||||
width="558"
|
||||
height="392"
|
||||
viewBox="0 0 558 392"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute w-full h-full inset-0 -top-40 lg:top-0 xl:-left-40 animate-pulse"
|
||||
style={{
|
||||
animationDuration: '20000ms',
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx="278.831"
|
||||
cy="112.952"
|
||||
r="278.5"
|
||||
transform="rotate(75 278.831 112.952)"
|
||||
fill="url(#paint0_radial_183_1691)"
|
||||
fillOpacity="0.2"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_183_1691"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(349.764 144.755) rotate(-132.179) scale(202.74 202.839)"
|
||||
>
|
||||
<stop stopColor="hsl(var(--foreground-default))" />
|
||||
<stop offset="1" stopColor="hsl(var(--foreground-default))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="sm:w-full sm_h-full sm:flex sm:justify-center">
|
||||
<svg
|
||||
width="1119"
|
||||
height="1119"
|
||||
viewBox="0 0 1119 1119"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="sm:w-auto -mb-72 sm:-mt-60 md:-mt-40 lg:-mt-12 xl:mt-0 animate-spinner !ease-linear transform"
|
||||
style={{
|
||||
animationDuration: '20000ms',
|
||||
}}
|
||||
>
|
||||
<g clipPath="url(#clip0_183_1690)">
|
||||
<circle cx="559.5" cy="559.5" r="496" fill="url(#paint1_radial_183_1690)" />
|
||||
<path
|
||||
d="M982.759 -15.7995C1100.79 61.9162 1134.95 153.728 1129.8 236.892C1124.68 319.611 1080.66 393.869 1041.31 437.283C968.75 168.701 692.591 9.3387 423.687 80.9161C430.529 20.4699 450.367 -27.8768 480.826 -63.4144C511.422 -99.1129 552.763 -121.922 602.496 -131.075C701.21 -149.241 833.009 -113.601 979.3 -18.0675L982.759 -15.7995Z"
|
||||
stroke="url(#paint2_radial_183_1690)"
|
||||
strokeWidth="1.15887"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint1_radial_183_1690"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(571.212 579.87) rotate(122.182) scale(542.117 690.275)"
|
||||
>
|
||||
<stop stopColor="hsl(var(--border-muted))" />
|
||||
<stop
|
||||
offset="0.716346"
|
||||
stopColor="hsl(var(--background-alternative-default))"
|
||||
/>
|
||||
<stop
|
||||
offset="0.754808"
|
||||
stopColor="hsl(var(--background-alternative-default))"
|
||||
/>
|
||||
<stop offset="1" stopColor="hsl(var(--border-strong))" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint2_radial_183_1690"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(814.301 175.03) rotate(-38.9601) scale(142.974 294.371)"
|
||||
>
|
||||
<stop stopColor="hsl(var(--foreground-default))" />
|
||||
<stop offset="1" stopColor="hsl(var(--foreground-default))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_183_1690">
|
||||
<rect width="1119" height="1119" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<svg
|
||||
width="1096"
|
||||
height="482"
|
||||
viewBox="0 0 1096 482"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute min-w-full inset-0 top-auto z-10"
|
||||
>
|
||||
<rect x="0.500488" width="1095" height="482" fill="url(#paint0_linear_183_1694)" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_183_1694"
|
||||
x1="922.165"
|
||||
y1="63.3564"
|
||||
x2="922.165"
|
||||
y2="419.772"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="hsl(var(--background-alternative-default))" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="hsl(var(--background-alternative-default))" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="relative w-full z-10 flex flex-col items-center mx-auto">
|
||||
<div className="flex gap-2 mb-4 md:mb-8">
|
||||
<div className="w-11 h-11 relative flex items-center justify-center bg-default border rounded-lg">
|
||||
<Image
|
||||
src="/images/supabase-logo-icon.svg"
|
||||
alt="Supabase icon"
|
||||
width={60}
|
||||
height={60}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{props.icon || props.title ? (
|
||||
<div className="mb-2 flex justify-center items-center gap-3">
|
||||
{props.title && (
|
||||
<h1
|
||||
className="text-brand font-mono uppercase tracking-widest text-sm"
|
||||
key={`product-name-${props.title}`}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn('flex flex-col gap-4 items-center')}>
|
||||
<div className="flex h-[150px] items-center">
|
||||
<div
|
||||
id="anim"
|
||||
className="will-change-transform leading-[120%] text-4xl sm:text-5xl md:text-6xl min-h-[4rem] max-w-2xl [&_.letter]:transform"
|
||||
>
|
||||
State of Startups
|
||||
</div>
|
||||
</div>
|
||||
<p className="p !text-foreground-light max-w-md">{props.subheader}</p>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex flex-col items-stretch sm:flex-row pt-2 sm:items-center gap-2">
|
||||
<PopupFrame
|
||||
trigger={
|
||||
<Button size="small" asChild>
|
||||
<span>Take the survey</span>
|
||||
</Button>
|
||||
}
|
||||
className="[&_.modal-content]:min-h-[650px] [&_.modal-content]:!h-[75vh] [&_.modal-content]:flex [&_.modal-content]:flex-col"
|
||||
>
|
||||
<div className="w-full !h-full flex-1 flex flex-col">
|
||||
<iframe
|
||||
src={`https://form.typeform.com/to/si6Z7XlW?embedded=true`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="0"
|
||||
className="w-full !min-h-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
</PopupFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StateOfStartupsPage
|
||||
@@ -916,6 +916,23 @@ export interface RequestDemoButtonClickedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User clicked the "Register" button in the State of Startups 2025 newsletter form.
|
||||
*
|
||||
* @group Events
|
||||
* @source www
|
||||
*/
|
||||
export interface RegisterStateOfStartups2025NewsletterClicked {
|
||||
action: 'register_for_state_of_startups_newsletter_clicked'
|
||||
properties: {
|
||||
/**
|
||||
* The source of the button click, e.g. homepage hero, cta banner, product page header.
|
||||
* If it states it came from the request demo form, it can come from different pages so refer to path name to determine.
|
||||
*/
|
||||
buttonLocation: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User clicked the sign-in button in various locations described in properties.
|
||||
*
|
||||
@@ -1380,6 +1397,7 @@ export type TelemetryEvent =
|
||||
| StartProjectButtonClickedEvent
|
||||
| SeeDocumentationButtonClickedEvent
|
||||
| RequestDemoButtonClickedEvent
|
||||
| RegisterStateOfStartups2025NewsletterClicked
|
||||
| SignInButtonClickedEvent
|
||||
| HelpButtonClickedEvent
|
||||
| ExampleProjectCardClickedEvent
|
||||
|
||||
10
packages/ui-patterns/Banners/AnnouncementBanner.tsx
Normal file
10
packages/ui-patterns/Banners/AnnouncementBanner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Announcement } from 'ui/src/layout/banners'
|
||||
import SOSBanner from './SOSBanner'
|
||||
|
||||
export const AnnouncementBanner = () => {
|
||||
return (
|
||||
<Announcement show={true} announcementKey="announcement_sos25">
|
||||
<SOSBanner />
|
||||
</Announcement>
|
||||
)
|
||||
}
|
||||
30
packages/ui-patterns/Banners/SOSBanner.tsx
Normal file
30
packages/ui-patterns/Banners/SOSBanner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Button } from 'ui/src/components/Button'
|
||||
|
||||
export function SOSBanner() {
|
||||
const pathname = usePathname()
|
||||
const isHomePage = pathname === '/'
|
||||
const isSurveyPage = pathname === '/state-of-startups'
|
||||
|
||||
if (isHomePage || isSurveyPage) return null
|
||||
|
||||
return (
|
||||
<div className="relative w-full p-2 flex items-center group justify-center text-foreground bg-alternative border-b border-muted transition-colors overflow-hidden">
|
||||
<div className="relative z-10 flex items-center justify-center">
|
||||
<div className="w-full flex gap-5 md:gap-10 items-center md:justify-center text-sm">
|
||||
<p className="flex gap-1.5 items-center text-brand font-mono uppercase tracking-widest text-sm">
|
||||
State of Startups 2025
|
||||
</p>
|
||||
<Button size="tiny" type="default" className="px-2 !leading-none text-xs" asChild>
|
||||
<Link href="/state-of-startups">Take the survey</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SOSBanner
|
||||
1
packages/ui-patterns/Banners/index.ts
Normal file
1
packages/ui-patterns/Banners/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './AnnouncementBanner'
|
||||
90
packages/ui-patterns/PopupFrame/index.tsx
Normal file
90
packages/ui-patterns/PopupFrame/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useBreakpoint } from 'common'
|
||||
import React, { ReactNode } from 'react'
|
||||
import { Modal, cn } from 'ui'
|
||||
|
||||
interface PopupFrameProps {
|
||||
triggerContainerClassName?: string
|
||||
trigger?: ReactNode
|
||||
onOpenCallback?: any
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function PopupFrame({
|
||||
triggerContainerClassName = '',
|
||||
trigger,
|
||||
onOpenCallback,
|
||||
className,
|
||||
children,
|
||||
}: PopupFrameProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const isMobile = useBreakpoint(768)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
return setOpen(false)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) setOpen(false)
|
||||
}, [isMobile])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={open}
|
||||
hideFooter
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'!bg-[#f8f9fa]/95 dark:!bg-[#1c1c1c]/80',
|
||||
'!border-[#e6e8eb]/90 dark:!border-[#282828]/90',
|
||||
'transition ease-out',
|
||||
'mx-auto backdrop-blur-md w-[calc(100%-2rem)]',
|
||||
className
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
// Only hide menu when clicking outside, not focusing outside
|
||||
// Prevents Firefox dropdown issue that immediately closes menu after opening
|
||||
if (e.type === 'dismissableLayer.pointerDownOutside') {
|
||||
setOpen(!open)
|
||||
}
|
||||
}}
|
||||
size="xxlarge"
|
||||
>
|
||||
<div className="device-frame !w-full h-full flex items-center justify-center">
|
||||
<div className="modal-group relative w-full h-full">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-foreground-light hover:text-foreground absolute -top-8 right-0"
|
||||
>
|
||||
<p className="text-xs">Close</p>
|
||||
</button>
|
||||
<div className="modal-content h-full !rounded-lg !border-none !overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onOpenCallback) onOpenCallback()
|
||||
setOpen(true)
|
||||
}}
|
||||
className={cn('w-full', triggerContainerClassName)}
|
||||
>
|
||||
{trigger ?? 'Expand'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
export * from './src/admonition'
|
||||
export * from './src/AssistantChat/AssistantChatForm'
|
||||
export * from './src/AuthenticatedDropdownMenu'
|
||||
export * from './Banners'
|
||||
export * from './src/CommandMenu'
|
||||
export * from './src/ComputeBadge'
|
||||
export * from './src/FilterBar'
|
||||
export * from './src/GlassPanel'
|
||||
export * from './src/InnerSideMenu'
|
||||
export * from './PopupFrame'
|
||||
export * from './src/ShimmeringLoader'
|
||||
export * from './src/TimestampInfo'
|
||||
export * from './src/Toc'
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -1352,8 +1352,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ai-commands
|
||||
animejs:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9426,8 +9426,8 @@ packages:
|
||||
amazon-cognito-identity-js@6.3.15:
|
||||
resolution: {integrity: sha512-G2mzTlGYHKYh9oZDO0Gk94xVQ4iY9GYWBaYScbDYvz05ps6dqi0IvdNx1Lxi7oA3tjS5X+mUN7/svFJJdOB9YA==}
|
||||
|
||||
animejs@3.2.2:
|
||||
resolution: {integrity: sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ==}
|
||||
animejs@4.0.2:
|
||||
resolution: {integrity: sha512-f0L/kSya2RF23iMSF/VO01pMmLwlAFoiQeNAvBXhEyLzIPd2/QTBRatwGUqkVCC6seaAJYzAkGir55N4SL+h3A==}
|
||||
|
||||
anser@2.3.2:
|
||||
resolution: {integrity: sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==}
|
||||
@@ -27828,8 +27828,8 @@ snapshots:
|
||||
js-cookie: 2.2.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
animejs@3.2.2: {}
|
||||
|
||||
animejs@4.0.2: {}
|
||||
|
||||
anser@2.3.2: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user