mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
State of Startups 2026 (#44248)
New pages for State of Startups 2026. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added "State of Startups 2026" interactive survey results page with dynamic charts, filterable analytics, and data visualizations across industry, funding stage, location, and technology adoption * Implemented animated statistics displays, word clouds, and participant directory throughout the results experience * Updated navigation to prominently feature the survey section * **Style** * Added smooth animated transitions for survey data elements and page sections <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bebd98a613
commit
aab4550c1b
48 files changed
+3653
-11
No files matched your search
@@ -0,0 +1,356 @@
|
||||
'use client'
|
||||
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import pageData from '~/data/surveys/state-of-startups-2026'
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { forwardRef, useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { StateOfStartupsAuroraHeader } from './components/StateOfStartupsAuroraHeader'
|
||||
import { SurveyChapter } from './components/SurveyChapter'
|
||||
import { SurveyChapterSection } from './components/SurveyChapterSection'
|
||||
import { SurveySectionBreak } from './components/SurveySectionBreak'
|
||||
|
||||
interface FloatingTocProps {
|
||||
tocRef: RefObject<HTMLDivElement>
|
||||
isTocOpen: boolean
|
||||
setIsTocOpen: (open: boolean) => void
|
||||
activeChapter: number
|
||||
}
|
||||
|
||||
function FloatingTableOfContents({
|
||||
tocRef,
|
||||
isTocOpen,
|
||||
setIsTocOpen,
|
||||
activeChapter,
|
||||
}: FloatingTocProps) {
|
||||
const currentChapter = pageData.pageChapters[activeChapter - 1]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={tocRef}
|
||||
className="fixed top-20 left-1/2 transform -translate-x-1/2 z-50"
|
||||
initial={{ opacity: 0, y: -8, x: '-50%' }}
|
||||
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||
exit={{ opacity: 0, y: -8, x: '-50%' }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait">
|
||||
{!isTocOpen ? (
|
||||
<motion.button
|
||||
key="pill"
|
||||
layoutId="toc-pill"
|
||||
layout
|
||||
onClick={() => setIsTocOpen(true)}
|
||||
className="flex flex-row items-center gap-2 shadow-xl rounded-full overflow-hidden px-2 pr-4 h-[30px] border border-overlay bg-overlay text-foreground font-mono uppercase text-xs"
|
||||
initial={{ opacity: 0, scale: 0.9, y: -6 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -6 }}
|
||||
transition={{
|
||||
layout: { duration: 0.5, type: 'spring' },
|
||||
duration: 0.15,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
layout="position"
|
||||
className="bg-surface-100 border border-surface-200 rounded-xl w-5 h-5 flex items-center justify-center text-foreground-light shrink-0"
|
||||
>
|
||||
{activeChapter}
|
||||
</motion.span>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={currentChapter?.shortTitle}
|
||||
layout
|
||||
className="text-foreground tracking-widest whitespace-nowrap"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}
|
||||
>
|
||||
{currentChapter?.shortTitle}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div
|
||||
key="dropdown"
|
||||
initial={{ opacity: 0, scale: 0.86, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.86, y: -10 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="origin-[50%_25%] bg-background/80 backdrop-blur-lg border border-default rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
<ol className="max-h-[60vh] overflow-y-auto py-2 flex flex-col">
|
||||
{pageData.pageChapters.map((chapter, chapterIndex) => (
|
||||
<motion.li
|
||||
key={chapterIndex + 1}
|
||||
initial={{ opacity: 0, y: -2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.1,
|
||||
ease: 'easeOut',
|
||||
delay:
|
||||
0.1 +
|
||||
Math.exp(-(pageData.pageChapters.length - chapterIndex - 1) * 0.3) * 0.2,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`#chapter-${chapterIndex + 1}`}
|
||||
onClick={() => setIsTocOpen(false)}
|
||||
className={cn(
|
||||
'block px-6 py-2 text-xs transition-colors font-mono uppercase tracking-wider text-center text-foreground-light hover:text-brand-link hover:bg-brand-300/25',
|
||||
chapterIndex + 1 === activeChapter &&
|
||||
'bg-brand-300/40 text-brand-link dark:text-brand'
|
||||
)}
|
||||
>
|
||||
{chapter.shortTitle}
|
||||
</Link>
|
||||
</motion.li>
|
||||
))}
|
||||
</ol>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StateOfStartups2026Content() {
|
||||
const [showFloatingToc, setShowFloatingToc] = useState(false)
|
||||
const [isTocOpen, setIsTocOpen] = useState(false)
|
||||
const [activeChapter, setActiveChapter] = useState(1)
|
||||
const tocRef = useRef<HTMLDivElement>(null)
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
const ctaBannerRef = useRef<HTMLElement>(null)
|
||||
|
||||
// Scroll detection to show floating ToC
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const heroElement = heroRef.current
|
||||
const ctaBannerElement = ctaBannerRef.current
|
||||
|
||||
if (heroElement && ctaBannerElement) {
|
||||
const heroRect = heroElement.getBoundingClientRect()
|
||||
const ctaBannerRect = ctaBannerElement.getBoundingClientRect()
|
||||
|
||||
// Show floating ToC when the hero section is completely out of view
|
||||
// but hide it when the CTA banner comes into view
|
||||
if (heroRect.bottom < 0 && ctaBannerRect.top > window.innerHeight) {
|
||||
setShowFloatingToc(true)
|
||||
} else {
|
||||
setShowFloatingToc(false)
|
||||
setIsTocOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Active chapter detection
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const chapters = pageData.pageChapters
|
||||
const scrollY = window.scrollY + 100 // Offset for better detection
|
||||
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
const chapterElement = document.getElementById(`chapter-${i + 1}`)
|
||||
if (chapterElement && scrollY >= chapterElement.offsetTop) {
|
||||
setActiveChapter(i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Close ToC when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const isOutsideFloating = tocRef.current && !tocRef.current.contains(event.target as Node)
|
||||
|
||||
if (isOutsideFloating) {
|
||||
setIsTocOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isTocOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isTocOpen])
|
||||
|
||||
return (
|
||||
<DefaultLayout className="bg-alternative overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{showFloatingToc && (
|
||||
<FloatingTableOfContents
|
||||
tocRef={tocRef}
|
||||
isTocOpen={isTocOpen}
|
||||
setIsTocOpen={setIsTocOpen}
|
||||
activeChapter={activeChapter}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Intro section */}
|
||||
<section ref={heroRef} className="w-full">
|
||||
<StateOfStartupsAuroraHeader />
|
||||
<SurveySectionBreak className="hidden md:block" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 max-w-[60rem] mx-auto md:border-x border-muted">
|
||||
{/* Intro text */}
|
||||
<div className="md:col-span-2 flex flex-col gap-4 px-8 py-10 border-b md:border-b-0 md:border-r border-muted text-foreground text-xl md:text-2xl text-balance">
|
||||
<p>{pageData.heroSection.subheader}</p>
|
||||
<p>{pageData.heroSection.cta}</p>
|
||||
</div>
|
||||
|
||||
{/* Table of contents */}
|
||||
<ol className="flex flex-col py-5">
|
||||
{pageData.pageChapters.map((chapter, chapterIndex) => (
|
||||
<li key={chapterIndex + 1}>
|
||||
<Link
|
||||
href={`#chapter-${chapterIndex + 1}`}
|
||||
className="group flex flex-row gap-5 py-3 pl-7 pr-8 font-mono uppercase tracking-wide text-sm transition-all text-foreground-light hover:text-brand-link hover:bg-brand-300/25"
|
||||
>
|
||||
<span className="text-xs rounded-full bg-surface-75 border border-surface-200 group-hover:border-brand-500/40 w-5 h-5 flex items-center justify-center group-hover:bg-brand-600/5">
|
||||
{chapterIndex + 1}
|
||||
</span>{' '}
|
||||
{chapter.shortTitle}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<SurveySectionBreak />
|
||||
</section>
|
||||
|
||||
{pageData.pageChapters.map((chapter, chapterIndex) => (
|
||||
<SurveyChapter
|
||||
key={chapterIndex + 1}
|
||||
number={chapterIndex + 1}
|
||||
shortTitle={chapter.shortTitle}
|
||||
title={chapter.title}
|
||||
description={chapter.description}
|
||||
pullQuote={chapter.pullQuote}
|
||||
>
|
||||
{chapter.sections.map((section, sectionIndex) => (
|
||||
<SurveyChapterSection
|
||||
key={sectionIndex + 1}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
stats={section.stats}
|
||||
charts={section.charts}
|
||||
wordCloud={section.wordCloud}
|
||||
summarizedAnswer={section.summarizedAnswer}
|
||||
rankedAnswersPair={section.rankedAnswersPair}
|
||||
/>
|
||||
))}
|
||||
</SurveyChapter>
|
||||
))}
|
||||
<CTABanner ref={ctaBannerRef} />
|
||||
<ParticipantsList />
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Component for the participants list
|
||||
const ParticipantsList = () => {
|
||||
const [shuffledParticipants, setShuffledParticipants] = useState(pageData.participantsList)
|
||||
|
||||
useEffect(() => {
|
||||
// Simple shuffle after mount, no animation because it's at the bottom of the page
|
||||
const shuffled = [...pageData.participantsList].sort(() => Math.random() - 0.5)
|
||||
setShuffledParticipants(shuffled)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="flex flex-col items-center gap-12 md:gap-20 px-4 py-20 md:py-28 text-center border-b border-muted">
|
||||
<div className="flex flex-col items-center gap-4 max-w-prose">
|
||||
<h2 className="text-foreground text-3xl text-balance">Thank you</h2>
|
||||
<p className="text-foreground-light text-lg text-balance max-w-prose">
|
||||
A special thanks to the following companies for participating in this year's survey.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap items-center justify-center gap-5 md:gap-7 max-w-7xl mx-auto px-4">
|
||||
{shuffledParticipants.map((participant, index) => (
|
||||
<li key={participant.company} className="">
|
||||
<Link
|
||||
href={participant.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs md:text-sm font-mono text-center tracking-widest uppercase text-foreground-lighter hover:text-brand-link transition-colors"
|
||||
>
|
||||
{participant.company}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Component for the 'Builders choose Supabase' CTA at the bottom of the page
|
||||
const CTABanner = forwardRef<HTMLElement>((props, ref) => {
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col items-center gap-4 px-4 py-32 text-center border-b border-muted"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at center 220%, hsl(var(--brand-400)), transparent 70%)',
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 max-w-prose">
|
||||
<h2 className="text-foreground text-5xl text-balance">Builders choose Supabase</h2>
|
||||
<p className="text-foreground-light text-lg">
|
||||
Supabase is the Postgres development platform. Build your startup with a Postgres
|
||||
database, Authentication, instant APIs, Edge Functions, Realtime subscriptions, Storage,
|
||||
and Vector embeddings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<Button asChild size="medium">
|
||||
<Link
|
||||
href="https://supabase.com/dashboard"
|
||||
onClick={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'start_project_button_clicked',
|
||||
properties: { buttonLocation: 'CTA Banner' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Start your project
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link
|
||||
href="/contact/sales"
|
||||
onClick={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'request_demo_button_clicked',
|
||||
properties: { buttonLocation: 'CTA Banner' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Request a demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
|
||||
CTABanner.displayName = 'CTABanner'
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
interface ProgressBarProps {
|
||||
className?: string
|
||||
backgroundClassName?: string
|
||||
foregroundClassName?: string
|
||||
animationDelay: string
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
className = 'h-4',
|
||||
backgroundClassName = 'bg-foreground-muted/80',
|
||||
foregroundClassName = 'bg-brand',
|
||||
animationDelay,
|
||||
reverse = false,
|
||||
}: ProgressBarProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Background bar (static) */}
|
||||
<div
|
||||
className={`h-full w-full ${backgroundClassName}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated foreground bar */}
|
||||
<div
|
||||
className={`absolute inset-0 h-full w-full ${foregroundClassName}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
clipPath: 'inset(0 100% 0 0)',
|
||||
animation: `terminalLine 10s steps(8, end) ${animationDelay} infinite ${reverse ? 'reverse' : ''}`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DecorativeProgressBarProps {
|
||||
/** Whether to reverse the animation direction */
|
||||
reverse?: boolean
|
||||
/** Whether to align to start or end */
|
||||
align?: 'start' | 'end'
|
||||
}
|
||||
|
||||
const PROGRESS_BARS = [
|
||||
{
|
||||
height: 'h-4',
|
||||
bgColor: 'bg-foreground-muted/80',
|
||||
fgColor: 'bg-brand-300',
|
||||
animationDelay: '0s',
|
||||
},
|
||||
{
|
||||
height: 'h-8',
|
||||
bgColor: 'bg-foreground-muted/50',
|
||||
fgColor: 'bg-brand',
|
||||
animationDelay: '0.3s',
|
||||
},
|
||||
{
|
||||
height: 'h-12',
|
||||
bgColor: 'bg-foreground-muted/20',
|
||||
fgColor: 'bg-brand-500',
|
||||
animationDelay: '0.6s',
|
||||
},
|
||||
]
|
||||
|
||||
export function DecorativeProgressBar({
|
||||
reverse = false,
|
||||
align = 'start',
|
||||
}: DecorativeProgressBarProps) {
|
||||
const progressBars = PROGRESS_BARS
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`flex flex-col w-full ${align === 'start' ? 'self-start' : 'self-end'}`}
|
||||
style={{
|
||||
maxWidth: 'calc(50% + 60rem / 2)',
|
||||
}}
|
||||
>
|
||||
{progressBars.map((bar, index) => (
|
||||
<ProgressBar
|
||||
key={`progress-bar-${index}`}
|
||||
className={bar.height}
|
||||
backgroundClassName={bar.bgColor}
|
||||
foregroundClassName={bar.fgColor}
|
||||
animationDelay={bar.animationDelay}
|
||||
reverse={reverse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useReducedMotion } from 'framer-motion'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import UnicornScene from 'unicornstudio-react/next'
|
||||
|
||||
export function StateOfStartupsAuroraHeader() {
|
||||
const [shaderLoaded, setShaderLoaded] = useState(false)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
return (
|
||||
<header
|
||||
className="relative w-full overflow-hidden min-h-[32svh] md:min-h-[50svh]"
|
||||
style={{ background: 'hsl(var(--background-alternative-default))' }}
|
||||
>
|
||||
{/* Aurora shader scene — hidden when user prefers reduced motion */}
|
||||
{!prefersReducedMotion && (
|
||||
<div aria-hidden="true" className="absolute inset-0">
|
||||
<UnicornScene
|
||||
jsonFilePath={'/images/state-of-startups/aurora-dithered-shader.json'}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full"
|
||||
dpi={1.5}
|
||||
fps={24}
|
||||
onLoad={() => setShaderLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder — visible until shader loads, fades out on load */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none transition-opacity [transition-duration:1200ms]"
|
||||
style={{
|
||||
opacity: shaderLoaded ? 0 : 1,
|
||||
background: 'hsl(var(--background-alternative-default))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, hsl(var(--background-alternative-default)) 0%, hsl(var(--background-alternative-default) / 0.9) 15%, transparent 90%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content — constrained to default container */}
|
||||
<div className="relative z-10 max-w-[60rem] mx-auto px-8 flex flex-col gap-4 justify-end pb-16 md:pb-18 min-h-[32svh] md:min-h-[50svh]">
|
||||
<p className="font-mono uppercase tracking-wide text-sm text-foreground-light">
|
||||
Supabase Presents
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-6xl xl:text-8xl font-light text-foreground leading-[0.92] tracking-tight">
|
||||
The State
|
||||
<br />
|
||||
of Startups <span className="text-brand-500 dark:text-brand font-medium">2026</span>
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { ProgressBar } from './DecorativeProgressBar'
|
||||
|
||||
interface StateOfStartupsHeaderProps {
|
||||
showProgressBars?: boolean
|
||||
}
|
||||
|
||||
const DiagonalStripes = () => (
|
||||
<div
|
||||
className="flex-grow"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
hsl(var(--border-muted)) 0px,
|
||||
hsl(var(--border-muted)) 1px,
|
||||
transparent 1px,
|
||||
transparent 8px
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const HeaderProgressBar = ({
|
||||
delay = 0,
|
||||
progressBgColor = 'bg-foreground-muted/80',
|
||||
progressFgColor,
|
||||
}: {
|
||||
delay?: number
|
||||
progressBgColor?: string
|
||||
progressFgColor?: string
|
||||
}) => (
|
||||
<div className="flex-grow">
|
||||
<ProgressBar
|
||||
className="h-full"
|
||||
backgroundClassName={progressBgColor}
|
||||
foregroundClassName={progressFgColor}
|
||||
animationDelay={`${delay}s`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface TextBlockProps {
|
||||
text: string
|
||||
bgColor: string
|
||||
textColor: string
|
||||
showProgressBar?: boolean
|
||||
}
|
||||
|
||||
const TextBlock = ({
|
||||
text,
|
||||
bgColor,
|
||||
textColor,
|
||||
showProgressBar = true,
|
||||
delay = 0,
|
||||
progressBgColor,
|
||||
progressFgColor,
|
||||
}: TextBlockProps & { delay?: number; progressBgColor?: string; progressFgColor?: string }) => (
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className={`inline-block ${bgColor}`}>
|
||||
<span
|
||||
className={`${textColor} text-[2rem] md:text-[4rem] px-2.5 md:px-8 py-1.5 md:py-3 inline-block leading-none tracking-tight`}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
{showProgressBar ? (
|
||||
<HeaderProgressBar
|
||||
delay={delay}
|
||||
progressBgColor={progressBgColor}
|
||||
progressFgColor={progressFgColor}
|
||||
/>
|
||||
) : (
|
||||
<DiagonalStripes />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export function StateOfStartupsHeader({ showProgressBars = true }: StateOfStartupsHeaderProps) {
|
||||
// Generate stable random delays between 0.2 and 2 seconds for each TextBlock
|
||||
const randomDelays = useMemo(
|
||||
() => [
|
||||
0.2 + Math.random() * 1.8,
|
||||
0.2 + Math.random() * 1.8,
|
||||
0.2 + Math.random() * 1.8,
|
||||
0.2 + Math.random() * 1.8,
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="mt-16 md:mt-32">
|
||||
<div className="max-w-[60rem] ml-8 md:mx-auto">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="sr-only">State of Startups 2026</h1>
|
||||
<TextBlock
|
||||
text="State"
|
||||
bgColor="bg-brand-400"
|
||||
textColor="text-white"
|
||||
showProgressBar={showProgressBars}
|
||||
delay={randomDelays[0]}
|
||||
progressBgColor="bg-surface-400 dark:bg-surface-300"
|
||||
progressFgColor="bg-brand-300"
|
||||
/>
|
||||
<TextBlock
|
||||
text="of"
|
||||
bgColor="bg-brand-500"
|
||||
textColor="text-brand-200"
|
||||
showProgressBar={showProgressBars}
|
||||
delay={randomDelays[1]}
|
||||
progressBgColor="bg-surface-400 dark:bg-surface-300"
|
||||
progressFgColor="bg-brand-300"
|
||||
/>
|
||||
<TextBlock
|
||||
text="Startups"
|
||||
bgColor="bg-brand-500"
|
||||
textColor="text-white"
|
||||
showProgressBar={showProgressBars}
|
||||
delay={randomDelays[2]}
|
||||
progressBgColor="bg-surface-400 dark:bg-surface-300"
|
||||
progressFgColor="bg-brand-500"
|
||||
/>
|
||||
<TextBlock
|
||||
text="2026"
|
||||
bgColor="bg-brand-600"
|
||||
textColor="text-brand-200"
|
||||
showProgressBar={showProgressBars}
|
||||
delay={randomDelays[3]}
|
||||
progressBgColor="bg-surface-400 dark:bg-surface-300"
|
||||
progressFgColor="bg-brand-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { SurveyPullQuote } from './SurveyPullQuote'
|
||||
|
||||
import './surveyResults.css'
|
||||
|
||||
import { AccentContext } from './accent-context'
|
||||
import { DecorativeProgressBar } from './DecorativeProgressBar'
|
||||
|
||||
interface SurveyChapterProps {
|
||||
number: number
|
||||
title: string
|
||||
shortTitle: string
|
||||
description: string
|
||||
pullQuote?: {
|
||||
quote: string
|
||||
author: string
|
||||
authorPosition: string
|
||||
authorAvatar: string
|
||||
}
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SurveyChapter({
|
||||
number,
|
||||
title,
|
||||
shortTitle,
|
||||
description,
|
||||
pullQuote,
|
||||
children,
|
||||
}: SurveyChapterProps) {
|
||||
const accent = 'green'
|
||||
|
||||
return (
|
||||
<AccentContext.Provider value={accent}>
|
||||
<section id={`chapter-${number}`} className="flex flex-col bg-alternative">
|
||||
<div className="flex flex-col">
|
||||
{/* Chapter header */}
|
||||
<header
|
||||
className={`flex flex-col gap-24 border-b border-muted`}
|
||||
style={{
|
||||
background:
|
||||
number % 2 !== 0
|
||||
? `radial-gradient(circle at 35% 280%, hsl(var(--brand-300)), transparent 70%)`
|
||||
: `radial-gradient(circle at 65% 280%, hsl(var(--brand-300)), transparent 70%)`,
|
||||
}}
|
||||
>
|
||||
{/* Decorative progress bar */}
|
||||
<DecorativeProgressBar
|
||||
reverse={number % 2 !== 0}
|
||||
align={number % 2 === 0 ? 'start' : 'end'}
|
||||
/>
|
||||
{/* Text content */}
|
||||
<div className="max-w-[60rem] mx-auto grid gap-y-4 sm:gap-y-8 grid-cols-1 md:grid-cols-3 text-balance pb-12">
|
||||
<div className="md:col-span-2 flex flex-col gap-4 px-8">
|
||||
<p
|
||||
className={`text-sm uppercase font-mono text-balance tracking-wider text-brand-link`}
|
||||
>
|
||||
{shortTitle}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl xl:text-6xl text-balance leading-tight tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="md:col-span-2 md:col-start-2 text-lg md:text-xl leading-relaxed text-foreground-light md:text-foreground text-balance px-8">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
{/* Chapter content */}
|
||||
<div className="flex flex-col">{children}</div>
|
||||
</div>
|
||||
|
||||
{pullQuote && (
|
||||
<SurveyPullQuote
|
||||
quote={pullQuote.quote}
|
||||
author={pullQuote.author}
|
||||
authorPosition={pullQuote.authorPosition}
|
||||
authorAvatar={pullQuote.authorAvatar}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</AccentContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useAccent } from './accent-context'
|
||||
import { SurveyStatCard } from './SurveyStatCard'
|
||||
import { SurveyWordCloud } from './SurveyWordCloud'
|
||||
import { SurveySummarizedAnswer } from './SurveySummarizedAnswer'
|
||||
import { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair'
|
||||
import { SurveySectionBreak } from './SurveySectionBreak'
|
||||
import { AcceleratorParticipationChart } from './charts/AcceleratorParticipationChart'
|
||||
import { RoleChart } from './charts/RoleChart'
|
||||
import { IndustryChart } from './charts/IndustryChart'
|
||||
import { FundingStageChart } from './charts/FundingStageChart'
|
||||
import { DatabasesChart } from './charts/DatabasesChart'
|
||||
import { AIModelsChart } from './charts/AIModelsChart'
|
||||
import { LocationChart } from './charts/LocationChart'
|
||||
import { SalesToolsChart } from './charts/SalesToolsChart'
|
||||
import { AICodingToolsChart } from './charts/AICodingToolsChart'
|
||||
import { RegularSocialMediaUseChart } from './charts/RegularSocialMediaUseChart'
|
||||
import { NewIdeasChart } from './charts/NewIdeasChart'
|
||||
import { InitialPayingCustomersChart } from './charts/InitialPayingCustomersChart'
|
||||
import { WorldOutlookChart } from './charts/WorldOutlookChart'
|
||||
import { BiggestChallengeChart } from './charts/BiggestChallengeChart'
|
||||
|
||||
interface SurveyChapterSectionProps {
|
||||
title: string
|
||||
description: string
|
||||
stats?: Array<{ percent: number; label: string }>
|
||||
charts?: string[]
|
||||
|
||||
wordCloud?: {
|
||||
label: string
|
||||
words: { text: string; count: number }[]
|
||||
}
|
||||
summarizedAnswer?: {
|
||||
label: string
|
||||
answers: string[]
|
||||
}
|
||||
rankedAnswersPair?: Array<{ label: string; answers: string[] }>
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SurveyChapterSection({
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
charts,
|
||||
wordCloud,
|
||||
summarizedAnswer,
|
||||
rankedAnswersPair,
|
||||
children,
|
||||
}: SurveyChapterSectionProps) {
|
||||
const accent = useAccent()
|
||||
|
||||
const eyebrowColor = 'text-brand-link dark:text-brand'
|
||||
|
||||
const chartComponents = {
|
||||
RoleChart,
|
||||
IndustryChart,
|
||||
FundingStageChart,
|
||||
AcceleratorParticipationChart,
|
||||
DatabasesChart,
|
||||
AICodingToolsChart,
|
||||
AIModelsChart,
|
||||
RegularSocialMediaUseChart,
|
||||
NewIdeasChart,
|
||||
InitialPayingCustomersChart,
|
||||
SalesToolsChart,
|
||||
WorldOutlookChart,
|
||||
BiggestChallengeChart,
|
||||
LocationChart,
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={title.toLowerCase().replace(/\s+/g, '-')} className="">
|
||||
<div className="max-w-[60rem] mx-auto flex flex-col md:border-x border-muted">
|
||||
<header className="grid grid-cols-1 md:grid-cols-3 text-balance">
|
||||
<h3
|
||||
className={`pt-8 pb-4 px-8 md:border-r border-muted font-mono uppercase tracking-wider text-sm ${eyebrowColor}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="pb-8 md:pt-8 px-8 md:col-span-2 text-foreground-light text-lg md:text-xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{stats && (
|
||||
<aside className="border-t border-muted flex flex-col xs:flex-row flex-wrap divide-y xs:divide-x xs:divide-y-0 divide-muted">
|
||||
{stats.map((stat, index) => (
|
||||
<SurveyStatCard key={index} percent={stat.percent} label={stat.label} />
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{charts?.map((chartName, index) => {
|
||||
const ChartComponent = chartComponents[chartName as keyof typeof chartComponents]
|
||||
return ChartComponent ? <ChartComponent key={index} /> : null
|
||||
})}
|
||||
|
||||
{rankedAnswersPair && <SurveyRankedAnswersPair rankedAnswersPair={rankedAnswersPair} />}
|
||||
|
||||
{wordCloud && <SurveyWordCloud label={wordCloud.label} answers={wordCloud.words} />}
|
||||
|
||||
{summarizedAnswer && (
|
||||
<SurveySummarizedAnswer
|
||||
label={summarizedAnswer.label}
|
||||
answers={summarizedAnswer.answers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<SurveySectionBreak />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
'use client'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import TwoOptionToggle from './TwoOptionToggle'
|
||||
import CodeBlock from '~/components/CodeBlock/CodeBlock'
|
||||
import { useAccent } from './accent-context'
|
||||
|
||||
// Separate Supabase client for survey project
|
||||
const externalSupabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
// Sentinel for “no filter”
|
||||
const NO_FILTER = 'unset'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface FilterConfig {
|
||||
label: string
|
||||
options: FilterOption[]
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
[key: string]: FilterConfig
|
||||
}
|
||||
|
||||
interface ChartDataItem {
|
||||
label: string
|
||||
value: number
|
||||
rawValue: number
|
||||
}
|
||||
|
||||
interface FilterColumnConfig {
|
||||
label: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
// Sometimes the label doesn't match the value, so we need to map them
|
||||
// Also, our options aren't always in a predicatable order, so we need to map them too
|
||||
const FILTER_COLUMN_CONFIGS: Record<string, FilterColumnConfig> = {
|
||||
team_size: {
|
||||
label: 'Team Size',
|
||||
options: ['1–10', '11–50', '51–100', '101–250', '250+'],
|
||||
},
|
||||
money_raised: {
|
||||
label: 'Money Raised',
|
||||
options: ['USD $0–10M', 'USD $11–50M', 'USD $51–100M', 'USD $100M+'],
|
||||
},
|
||||
person_age: {
|
||||
label: 'Age',
|
||||
options: ['18–21', '22–29', '30–39', '40–49', '50–59', '60+'],
|
||||
},
|
||||
location: {
|
||||
label: 'Location',
|
||||
options: [
|
||||
'Africa',
|
||||
'Asia',
|
||||
'Europe',
|
||||
'Middle East',
|
||||
'North America',
|
||||
'South America',
|
||||
'Remote',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function useFilterOptions(filterColumns: string[]) {
|
||||
// Build filters synchronously since everything is predefined
|
||||
const filters: Filters = {}
|
||||
|
||||
for (const column of filterColumns) {
|
||||
const config = FILTER_COLUMN_CONFIGS[column]
|
||||
|
||||
if (!config) {
|
||||
console.warn(`No configuration found for filter column: ${column}`)
|
||||
continue
|
||||
}
|
||||
|
||||
filters[column] = {
|
||||
label: config.label,
|
||||
options: config.options.map((option) => ({ value: option, label: option })),
|
||||
}
|
||||
}
|
||||
|
||||
return { filters }
|
||||
}
|
||||
|
||||
// Fetch survey data using secure database functions
|
||||
function useSurveyData(
|
||||
shouldFetch: boolean,
|
||||
functionName: string,
|
||||
functionParams: (activeFilters: Record<string, string>) => Record<string, any>,
|
||||
activeFilters: Record<string, string>
|
||||
) {
|
||||
const [chartData, setChartData] = useState<ChartDataItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetch) return
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
let data, fetchError
|
||||
|
||||
const functionParamsData = functionParams(activeFilters)
|
||||
const { data: functionData, error: functionError } = await externalSupabase.rpc(
|
||||
functionName,
|
||||
functionParamsData
|
||||
)
|
||||
data = functionData
|
||||
fetchError = functionError
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Error executing SQL query:', fetchError)
|
||||
setError(fetchError.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate total for percentage calculation
|
||||
const total = data.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.count || row.total),
|
||||
0
|
||||
)
|
||||
|
||||
// Transform the data to match chart format
|
||||
const processedData = data.map((row: any) => {
|
||||
const count = parseInt(row.count || row.total)
|
||||
const rawPercentage = total > 0 ? (count / total) * 100 : 0
|
||||
const roundedPercentage = Math.round(rawPercentage)
|
||||
|
||||
return {
|
||||
label: row.label || row.value || row[Object.keys(row)[0]], // Get the first column as label
|
||||
value: roundedPercentage,
|
||||
rawValue: rawPercentage, // Keep the raw value for bar scaling
|
||||
}
|
||||
})
|
||||
|
||||
setChartData(processedData)
|
||||
} catch (err: any) {
|
||||
console.error('Error in fetchData:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [shouldFetch, functionName, functionParams, activeFilters])
|
||||
|
||||
return { chartData, isLoading, error }
|
||||
}
|
||||
|
||||
interface SurveyChartProps {
|
||||
title: string
|
||||
targetColumn: string
|
||||
filterColumns: string[]
|
||||
generateSQLQuery?: (activeFilters: Record<string, string>) => string
|
||||
functionName: string
|
||||
}
|
||||
|
||||
export function SurveyChart({
|
||||
title,
|
||||
targetColumn, // Used for SQL query generation when generateSQLQuery is provided
|
||||
filterColumns,
|
||||
generateSQLQuery,
|
||||
functionName,
|
||||
}: SurveyChartProps) {
|
||||
const accent = useAccent()
|
||||
const accentBg = 'hsl(var(--brand-300))'
|
||||
const accentBarFg = 'bg-brand'
|
||||
const accentBarText = 'text-brand-link dark:text-brand'
|
||||
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldAnimateBars, setShouldAnimateBars] = useState(false)
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
|
||||
|
||||
// Intersection observer to trigger chart data loading via database function
|
||||
useEffect(() => {
|
||||
const chartRefCurrent = chartRef.current
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !hasLoadedOnce) {
|
||||
setIsInView(true)
|
||||
setHasLoadedOnce(true)
|
||||
observer.disconnect() // Only trigger once
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
rootMargin: '128px', // Start loading before the component comes into view
|
||||
}
|
||||
)
|
||||
|
||||
if (chartRefCurrent) {
|
||||
observer.observe(chartRefCurrent)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (chartRefCurrent) {
|
||||
observer.unobserve(chartRefCurrent)
|
||||
}
|
||||
}
|
||||
}, [hasLoadedOnce])
|
||||
|
||||
// Each chart uses a subset of available filters, defined below
|
||||
const { filters } = useFilterOptions(filterColumns)
|
||||
|
||||
// Start with all filters unset (showing "all")
|
||||
const [activeFilters, setActiveFilters] = useState(
|
||||
filterColumns.reduce(
|
||||
(acc: Record<string, string>, col: string) => ({ ...acc, [col]: NO_FILTER }),
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
// Build function parameters based on filterColumns
|
||||
const buildFunctionParams = useCallback((activeFilters: Record<string, string>) => {
|
||||
const params: Record<string, any> = {}
|
||||
|
||||
// Convert single values to arrays for the function parameters
|
||||
if (activeFilters.person_age && activeFilters.person_age !== 'unset') {
|
||||
params.person_age_filter = [activeFilters.person_age]
|
||||
}
|
||||
if (activeFilters.location && activeFilters.location !== 'unset') {
|
||||
params.location_filter = [activeFilters.location]
|
||||
}
|
||||
if (activeFilters.money_raised && activeFilters.money_raised !== 'unset') {
|
||||
params.money_raised_filter = [activeFilters.money_raised]
|
||||
}
|
||||
if (activeFilters.team_size && activeFilters.team_size !== 'unset') {
|
||||
params.team_size_filter = [activeFilters.team_size]
|
||||
}
|
||||
|
||||
return params
|
||||
}, [])
|
||||
|
||||
// Use the custom hook to fetch data
|
||||
const {
|
||||
chartData,
|
||||
isLoading: dataLoading,
|
||||
error: dataError,
|
||||
} = useSurveyData(isInView, functionName, buildFunctionParams, activeFilters)
|
||||
|
||||
// Reset animation state when filters change
|
||||
useEffect(() => {
|
||||
setShouldAnimateBars(false)
|
||||
}, [activeFilters])
|
||||
|
||||
// Trigger bar animation when data loads
|
||||
useEffect(() => {
|
||||
if (!dataLoading && chartData.length > 0 && !shouldAnimateBars) {
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => {
|
||||
setShouldAnimateBars(true)
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [dataLoading, chartData.length, shouldAnimateBars])
|
||||
|
||||
const [view, setView] = useState<'chart' | 'sql'>('chart')
|
||||
|
||||
// Handle both view change and expansion via a wrapper function
|
||||
const handleViewChange = (newView: 'chart' | 'sql') => {
|
||||
setView(newView)
|
||||
setIsExpanded(true)
|
||||
}
|
||||
|
||||
const setFilterValue = (filterKey: string, value: string) => {
|
||||
setActiveFilters((prev: Record<string, string>) => ({
|
||||
...prev,
|
||||
[filterKey]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
// Find the maximum value for scaling the bars
|
||||
const maxValue = chartData.length > 0 ? Math.max(...chartData.map((item) => item.value)) : 0
|
||||
|
||||
// State for expand/collapse button
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// Fixed height for all states (loading, error, loaded collapsed)
|
||||
const FIXED_HEIGHT = 300 // px
|
||||
const BUTTON_AREA_HEIGHT = 40 // px
|
||||
const CHART_HEIGHT = FIXED_HEIGHT - BUTTON_AREA_HEIGHT // px
|
||||
|
||||
const skeletonData = [
|
||||
{ label: 'Loading', value: 0, rawValue: 0 },
|
||||
{ label: 'Loading', value: 0, rawValue: 0 },
|
||||
{ label: 'Loading', value: 0, rawValue: 0 },
|
||||
]
|
||||
|
||||
const displayData = dataLoading ? skeletonData : chartData || []
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chartRef}
|
||||
className="w-full bg-200 border-t border-muted"
|
||||
style={{
|
||||
background: `radial-gradient(circle at center -150%, ${accentBg}, transparent 80%), radial-gradient(ellipse at center 230%, hsl(var(--background-surface-200)), transparent 75%)`,
|
||||
}}
|
||||
>
|
||||
<header className="px-8 py-8">
|
||||
<p className="text-foreground/30 text-sm font-mono uppercase tracking-widest">Q&A</p>
|
||||
<h3 className="text-foreground text-xl tracking-tight text-balance">{title}</h3>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
{/* Filters and toggle */}
|
||||
<div className="flex flex-row flex-wrap gap-6 px-8 pb-4 justify-between">
|
||||
{filters && activeFilters && setFilterValue && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(filters).map(([filterKey, filterConfig]) => (
|
||||
<SurveyFilter
|
||||
key={filterKey}
|
||||
filterKey={filterKey}
|
||||
filterConfig={filterConfig}
|
||||
selectedValue={activeFilters[filterKey]}
|
||||
setFilterValue={setFilterValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden xs:block">
|
||||
<TwoOptionToggle
|
||||
options={['SQL', 'chart']}
|
||||
activeOption={view}
|
||||
onClickOption={handleViewChange}
|
||||
borderOverride="border-overlay"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
key={view} // Force a re-render when view changes
|
||||
className="overflow-hidden relative"
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isExpanded ? 'auto' : `${FIXED_HEIGHT}px`,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{dataError ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-danger">Error: {dataError}</p>
|
||||
</div>
|
||||
) : view === 'chart' ? (
|
||||
<div className="flex flex-col h-full w-full justify-between px-8 pt-4 pb-12 min-h-[300px]">
|
||||
{chartData.length > 0 ? (
|
||||
<div
|
||||
className="flex flex-col gap-10"
|
||||
style={{ height: isExpanded ? 'auto' : `${CHART_HEIGHT}px` }}
|
||||
>
|
||||
{displayData.map((item, index) => (
|
||||
<div key={index} className="flex flex-col">
|
||||
{/* Text above the bar */}
|
||||
<div
|
||||
className={`mb-2 flex flex-row justify-between text-sm font-mono uppercase tracking-widest tabular-nums transition-colors duration-300 ${
|
||||
shouldAnimateBars
|
||||
? item.value === maxValue
|
||||
? accentBarText
|
||||
: 'text-foreground'
|
||||
: 'text-foreground-muted'
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{item.value < 1 ? '<1%' : `${item.value}%`}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="h-4 relative overflow-hidden"
|
||||
style={
|
||||
{
|
||||
'--bar-value': item.value,
|
||||
'--reference': maxValue,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Background pattern for the entire bar */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none bg-foreground-muted/60"
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filled portion of the bar */}
|
||||
<div
|
||||
className={`h-full relative bg-surface-100`}
|
||||
style={{
|
||||
width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))`,
|
||||
clipPath: shouldAnimateBars ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
|
||||
transition: `clip-path 0.5s steps(${Math.max(2, Math.floor((item.value / 100) * 12))}, end) ${index * 0.05}s`,
|
||||
}}
|
||||
>
|
||||
{/* Foreground pattern for the filled portion */}
|
||||
<div
|
||||
className={`absolute inset-0 pointer-events-none ${item.value === maxValue ? accentBarFg : 'bg-foreground-light'}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 pt-8 flex flex-col items-center justify-center gap-4">
|
||||
<p className="text-foreground-lighter text-balance text-center">
|
||||
No responses match those filters. Maybe next year?
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
onClick={() =>
|
||||
setActiveFilters(
|
||||
filterColumns.reduce(
|
||||
(acc: Record<string, string>, col: string) => ({
|
||||
...acc,
|
||||
[col]: NO_FILTER,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-8 pt-4 pb-8">
|
||||
{generateSQLQuery ? (
|
||||
<CodeBlock lang="sql">{generateSQLQuery(activeFilters)}</CodeBlock>
|
||||
) : (
|
||||
<CodeBlock lang="ts">
|
||||
{`// Function call: ${functionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`}
|
||||
</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand button overlay - only show for chart view */}
|
||||
{view === 'chart' &&
|
||||
!isExpanded &&
|
||||
!dataLoading &&
|
||||
!dataError &&
|
||||
chartData.length > 3 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center py-4 bg-gradient-to-b from-transparent to-background">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="shadow-sm"
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to build SQL WHERE clauses from active filters
|
||||
// Accepts optional initialClauses for charts that need extra constraints
|
||||
// (e.g., "column IS NOT NULL")
|
||||
// Note: This function is used by chart components that generate SQL queries
|
||||
export function buildWhereClause(
|
||||
activeFilters: Record<string, string>,
|
||||
initialClauses: string[] = []
|
||||
) {
|
||||
const whereClauses: string[] = [...initialClauses]
|
||||
|
||||
for (const [column, value] of Object.entries(activeFilters)) {
|
||||
if (value && value !== NO_FILTER) {
|
||||
whereClauses.push(`${column} = '${value}'`)
|
||||
}
|
||||
}
|
||||
|
||||
return whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
|
||||
}
|
||||
|
||||
// Dropdown filter component
|
||||
function SurveyFilter({
|
||||
filterKey,
|
||||
filterConfig,
|
||||
selectedValue,
|
||||
setFilterValue,
|
||||
}: {
|
||||
filterKey: string
|
||||
filterConfig: {
|
||||
label: string
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
selectedValue: string
|
||||
setFilterValue: (filterKey: string, value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
|
||||
>
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">{filterConfig.label}</p>
|
||||
{selectedValue !== NO_FILTER && <p className="text-foreground">{selectedValue}</p>}
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{filterConfig.options
|
||||
.filter((opt) => opt.value !== NO_FILTER)
|
||||
.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setFilterValue(filterKey, option.value)}
|
||||
className={selectedValue === option.value ? 'text-brand-600' : ''}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{selectedValue !== NO_FILTER && (
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterValue(filterKey, NO_FILTER)}
|
||||
className="text-foreground-lighter"
|
||||
>
|
||||
Clear
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Image from 'next/image'
|
||||
import { SurveySectionBreak } from './SurveySectionBreak'
|
||||
|
||||
export function SurveyPullQuote({
|
||||
quote,
|
||||
author,
|
||||
authorPosition,
|
||||
authorAvatar,
|
||||
}: {
|
||||
quote: string
|
||||
author: string
|
||||
authorPosition: string
|
||||
authorAvatar: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<aside className="relative border-b border-muted md:border-b-0">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none bg-surface-400 dark:bg-surface-75"
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px', // Match maskSize in SurveySectionBreak
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
<div className="relative max-w-[60rem] mx-auto md:border-x border-muted flex flex-col gap-4 text-center items-center px-6 py-24 bg-alternative">
|
||||
<p className="text-foreground-lighter text-2xl tracking-tight text-balance max-w-prose">
|
||||
“{quote}”
|
||||
</p>
|
||||
<Image
|
||||
src={authorAvatar || '/images/twitter-profiles/qhvO9V6x_400x400.jpg'}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={`${author}'s avatar`}
|
||||
className="h-10 w-10 overflow-hidden rounded-full border border-control"
|
||||
/>
|
||||
<p>
|
||||
{author}
|
||||
<br />
|
||||
<span className="text-foreground-muted text-sm">{authorPosition}</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
<SurveySectionBreak className="hidden md:block" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useAccent } from './accent-context'
|
||||
|
||||
export function SurveyRankedAnswersPair({
|
||||
rankedAnswersPair,
|
||||
}: {
|
||||
rankedAnswersPair: Array<{ label: string; answers: string[] }>
|
||||
}) {
|
||||
const accent = useAccent()
|
||||
|
||||
const accentBlocks = ['bg-brand', 'bg-brand-500', 'bg-brand-300']
|
||||
|
||||
const accentRankText = 'text-brand'
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col sm:flex-row flex-wrap divide-x divide-y divide-muted divide-opacity-50">
|
||||
{rankedAnswersPair.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-6 md:gap-12 flex-1 ${index % 2 === 0 ? 'flex-col' : 'flex-col sm:flex-col-reverse'}`}
|
||||
>
|
||||
{/* Decorative progress bar */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`flex ${index % 2 === 0 ? 'flex-row items-start' : 'flex-row-reverse items-start sm:items-end'}`}
|
||||
>
|
||||
{item.answers.map((answer, answerIndex) => (
|
||||
<div
|
||||
key={answerIndex}
|
||||
className={`${['h-20 md:h-32', 'h-12 md:h-20', 'h-4 md:h-8'][answerIndex] || 'hidden'} w-full pointer-events-none ${accentBlocks[answerIndex] || accentBlocks[2]}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
<div
|
||||
className={`px-6 py-8 flex ${index % 2 === 0 ? 'flex-col' : 'flex-col sm:flex-col-reverse'} gap-12`}
|
||||
>
|
||||
<ol className="flex flex-col gap-3">
|
||||
{item.answers.map((answer, answerIndex) => (
|
||||
<li key={answerIndex} className="flex flex-col gap-2">
|
||||
<span className={`text-sm font-mono ${accentRankText}`}>#{answerIndex + 1}</span>
|
||||
<span
|
||||
className={`${['text-2xl', 'text-lg', 'text-sm'][answerIndex] || 'text-lg'} text-foreground`}
|
||||
>
|
||||
{answer}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="text-foreground-lighter text-sm font-mono uppercase tracking-wider">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from 'ui'
|
||||
|
||||
export function SurveySectionBreak({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn('border-y border-muted', className)}>
|
||||
<div className="max-w-[60rem] mx-auto md:border-x border-muted">
|
||||
<div
|
||||
className="h-14 md:h-18 lg:h-20 xl:h-24 bg-surface-400 dark:bg-surface-75"
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px', // Match maskSize in SurveyPullQuote
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAccent } from './accent-context'
|
||||
|
||||
const ANIMATION_DURATION = 600
|
||||
|
||||
// These values are calculated via static SQL queries under 'Key Stats' and rounded for display in data/surveys/state-of-startups-2025.tsx file
|
||||
export function SurveyStatCard({ label, percent }: { label: string; percent: number }) {
|
||||
const [displayValue, setDisplayValue] = useState(0)
|
||||
const [hasAnimated, setHasAnimated] = useState(false)
|
||||
const [shouldAnimateBar, setShouldAnimateBar] = useState(false)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const accent = useAccent()
|
||||
|
||||
const accentBg = 'bg-brand'
|
||||
const accentText = 'text-brand'
|
||||
|
||||
useEffect(() => {
|
||||
if (!percent || hasAnimated) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setHasAnimated(true)
|
||||
setShouldAnimateBar(true)
|
||||
|
||||
// Start counting animation
|
||||
const startTime = Date.now()
|
||||
const duration = ANIMATION_DURATION
|
||||
const startValue = 0
|
||||
const endValue = percent
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4)
|
||||
const currentValue = Math.round(startValue + (endValue - startValue) * easeOutQuart)
|
||||
|
||||
setDisplayValue(currentValue)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
|
||||
const currentCardRef = cardRef.current
|
||||
if (currentCardRef) {
|
||||
observer.observe(currentCardRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentCardRef) {
|
||||
observer.unobserve(currentCardRef)
|
||||
}
|
||||
}
|
||||
}, [percent, hasAnimated])
|
||||
|
||||
return (
|
||||
<div ref={cardRef} className="flex-1 px-8 py-8 flex flex-col gap-4">
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="h-2 relative overflow-hidden"
|
||||
style={
|
||||
{
|
||||
'--bar-value': percent,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Background pattern for the entire bar */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none bg-foreground-muted/80"
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filled portion of the bar */}
|
||||
<div
|
||||
className={`h-full relative bg-surface-100`}
|
||||
style={{
|
||||
width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))`,
|
||||
clipPath: shouldAnimateBar ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
|
||||
transition: `clip-path 0.5s steps(${Math.max(2, Math.floor((percent / 100) * 12))}, end)`,
|
||||
}}
|
||||
>
|
||||
{/* Foreground pattern for the filled portion */}
|
||||
<div
|
||||
className={`absolute inset-0 pointer-events-none ${accentBg}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Text */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={`md:-ml-1 md:mt-8 text-2xl md:text-6xl font-mono tracking-tight inline-block flex flex-row items-baseline ${hasAnimated ? accentText : 'text-foreground-muted'} transition-colors duration-1000`}
|
||||
>
|
||||
{displayValue}
|
||||
<span className="md:text-4xl">%</span>
|
||||
</p>
|
||||
<p className="text-foreground-light text-sm text-balance md:mr-6">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAccent } from './accent-context'
|
||||
import './surveyResults.css'
|
||||
|
||||
const ROTATION_DURATION = 4000
|
||||
|
||||
export function SurveySummarizedAnswer({ label, answers }: { label: string; answers: string[] }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const [isBlinking, setIsBlinking] = useState(false)
|
||||
const accent = useAccent()
|
||||
|
||||
const accentBg = 'bg-brand'
|
||||
|
||||
useEffect(() => {
|
||||
if (answers.length <= 1) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % answers.length)
|
||||
}, ROTATION_DURATION)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [answers.length])
|
||||
|
||||
useEffect(() => {
|
||||
// First, reset the bar to 0%
|
||||
setIsAnimating(false)
|
||||
setIsBlinking(false)
|
||||
|
||||
// Then start the animation after a brief delay to ensure the reset is applied
|
||||
const startTimer = setTimeout(() => {
|
||||
setIsAnimating(true)
|
||||
}, 50)
|
||||
|
||||
// Stop the animation after 3 seconds
|
||||
const stopTimer = setTimeout(() => {
|
||||
setIsAnimating(false)
|
||||
}, 4050)
|
||||
|
||||
// Start blinking shortly before text change
|
||||
const blinkTimer = setTimeout(() => {
|
||||
setIsBlinking(true)
|
||||
}, 3700)
|
||||
|
||||
// Stop blinking when text changes
|
||||
const stopBlinkTimer = setTimeout(() => {
|
||||
setIsBlinking(false)
|
||||
}, ROTATION_DURATION)
|
||||
|
||||
return () => {
|
||||
clearTimeout(startTimer)
|
||||
clearTimeout(stopTimer)
|
||||
clearTimeout(blinkTimer)
|
||||
clearTimeout(stopBlinkTimer)
|
||||
}
|
||||
}, [currentIndex])
|
||||
|
||||
return (
|
||||
<div className="border-t border-muted flex flex-col gap-6 px-8 py-16 sm:items-center sm:text-center">
|
||||
<p
|
||||
className="text-foreground text-xl tracking-tight transition-opacity duration-500"
|
||||
style={{
|
||||
animation: isBlinking ? 'blink 100ms infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
{answers[currentIndex]}
|
||||
</p>
|
||||
{/* Decorative progress bar */}
|
||||
<div aria-hidden="true" className="w-24 h-1 relative overflow-hidden">
|
||||
{/* Background pattern for the entire bar */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none bg-foreground-muted/80"
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filled portion of the bar - timer-based */}
|
||||
<div
|
||||
key={currentIndex}
|
||||
className={`h-full relative bg-surface-100`}
|
||||
style={{
|
||||
width: '100%',
|
||||
clipPath: isAnimating ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
|
||||
transition: isAnimating ? 'clip-path 3s steps(3, end)' : 'clip-path 0s',
|
||||
}}
|
||||
>
|
||||
{/* Foreground pattern for the filled portion */}
|
||||
<div
|
||||
className={`absolute inset-0 pointer-events-none ${accentBg}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="w-full text-foreground-lighter text-sm font-mono uppercase tracking-wider">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAccent } from './accent-context'
|
||||
|
||||
const TIMER_DURATION = 3000
|
||||
|
||||
export function SurveyWordCloud({
|
||||
answers,
|
||||
label,
|
||||
}: {
|
||||
answers: { text: string; count: number }[]
|
||||
label: string
|
||||
}) {
|
||||
const [currentItems, setCurrentItems] = useState<{ text: string; count: number }[]>([])
|
||||
const [isRotating, setIsRotating] = useState(false)
|
||||
const [scramblingTexts, setScramblingTexts] = useState<string[]>([])
|
||||
const accent = useAccent()
|
||||
|
||||
const accentBg = 'bg-brand'
|
||||
|
||||
// Calculate the range within the current context
|
||||
const counts = answers.map((answer) => answer.count)
|
||||
const maxCount = Math.max(...counts)
|
||||
|
||||
// Initialize with first 12 items
|
||||
useEffect(() => {
|
||||
const initialItems = answers.slice(0, 12)
|
||||
setCurrentItems(initialItems)
|
||||
setScramblingTexts(initialItems.map((item) => item.text))
|
||||
}, [answers])
|
||||
|
||||
// Start rotation after 3 seconds
|
||||
useEffect(() => {
|
||||
if (answers.length <= 12) return // No need to rotate if we have 12 or fewer items
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsRotating(true)
|
||||
}, TIMER_DURATION)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [answers.length])
|
||||
|
||||
// Airport board scramble effect
|
||||
useEffect(() => {
|
||||
if (!isRotating || answers.length <= 12 || scramblingTexts.length === 0) return
|
||||
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const characterDelay = 50 // 50ms between each character change
|
||||
|
||||
const rotateItems = () => {
|
||||
const newItems = currentItems.map((item, index) => {
|
||||
// Calculate next item index with wrapping
|
||||
const nextItemIndex = (index + Math.floor(Date.now() / TIMER_DURATION)) % answers.length
|
||||
return answers[nextItemIndex]
|
||||
})
|
||||
|
||||
setCurrentItems(newItems)
|
||||
|
||||
// Start scramble animation for each item
|
||||
newItems.forEach((newItem, index) => {
|
||||
const oldText = scramblingTexts[index] || ''
|
||||
const newText = newItem.text
|
||||
const maxLength = Math.max(oldText.length, newText.length)
|
||||
|
||||
for (let charIndex = 0; charIndex < maxLength; charIndex++) {
|
||||
setTimeout(() => {
|
||||
const newChar = newText[charIndex]
|
||||
|
||||
// If we're past the old text length, just show the new char
|
||||
if (charIndex >= oldText.length) {
|
||||
if (newChar) {
|
||||
// Only animate if there's actually a character to show
|
||||
setScramblingTexts((prev) => {
|
||||
const updated = [...prev]
|
||||
updated[index] = (updated[index] || '').substring(0, charIndex) + newChar
|
||||
return updated
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we're past the new text length, remove the character
|
||||
if (charIndex >= newText.length) {
|
||||
setScramblingTexts((prev) => {
|
||||
const updated = [...prev]
|
||||
updated[index] = (updated[index] || '').substring(0, charIndex)
|
||||
return updated
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Scramble through alphabet for existing characters that are being replaced
|
||||
let scrambleCount = 0
|
||||
const scrambleInterval = setInterval(() => {
|
||||
const randomChar = alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
|
||||
setScramblingTexts((prev) => {
|
||||
const updated = [...prev]
|
||||
const currentText = updated[index] || ''
|
||||
updated[index] =
|
||||
currentText.substring(0, charIndex) +
|
||||
randomChar +
|
||||
currentText.substring(charIndex + 1)
|
||||
return updated
|
||||
})
|
||||
|
||||
scrambleCount++
|
||||
if (scrambleCount >= 8) {
|
||||
// Scramble 8 times before settling
|
||||
clearInterval(scrambleInterval)
|
||||
setScramblingTexts((prev) => {
|
||||
const updated = [...prev]
|
||||
const currentText = updated[index] || ''
|
||||
updated[index] =
|
||||
currentText.substring(0, charIndex) +
|
||||
newChar +
|
||||
currentText.substring(charIndex + 1)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
}, characterDelay)
|
||||
}, charIndex * 100) // Stagger each character
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Rotate every 3 seconds
|
||||
const interval = setInterval(rotateItems, TIMER_DURATION)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isRotating, answers, scramblingTexts, currentItems])
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col gap-12 px-8 border-t border-muted py-12">
|
||||
<ol className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-16 gap-y-10">
|
||||
{currentItems.map(({ text, count }, index) => (
|
||||
<li key={`${text}-${index}`} className="flex flex-col gap-2 items-start">
|
||||
{/* Progress bar */}
|
||||
{count && (
|
||||
<div
|
||||
className="w-full h-1 relative overflow-hidden"
|
||||
style={
|
||||
{
|
||||
'--bar-value': count,
|
||||
'--reference': maxCount,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Background pattern for the entire bar */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none bg-foreground-muted/80"
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filled portion of the bar */}
|
||||
<div
|
||||
className={`h-full relative bg-surface-100`}
|
||||
style={{
|
||||
width: `calc(max(0.5%, (${count} / ${maxCount}) * 100%))`,
|
||||
clipPath: isRotating ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
|
||||
transition: `clip-path 0.5s steps(${Math.max(2, Math.floor((count / maxCount) * 12))}, end)`,
|
||||
}}
|
||||
>
|
||||
{/* Foreground pattern for the filled portion */}
|
||||
<div
|
||||
className={`absolute inset-0 pointer-events-none ${accentBg}`}
|
||||
style={{
|
||||
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
|
||||
maskSize: '4px',
|
||||
maskRepeat: 'repeat',
|
||||
maskPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-mono md:text-lg text-foreground tracking-wide uppercase">
|
||||
{scramblingTexts[index] || text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="w-full text-foreground-lighter text-sm font-mono uppercase tracking-wider sm:text-center">
|
||||
{label}
|
||||
</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { cn } from 'ui'
|
||||
|
||||
interface TwoOptionToggleProps {
|
||||
options: any
|
||||
width?: number
|
||||
activeOption: any
|
||||
onClickOption: any
|
||||
borderOverride: string
|
||||
}
|
||||
|
||||
const TwoOptionToggle = ({
|
||||
options,
|
||||
width = 50,
|
||||
activeOption,
|
||||
onClickOption,
|
||||
borderOverride = 'border-stronger',
|
||||
}: TwoOptionToggleProps) => {
|
||||
const buttonStyle = (
|
||||
isActive: boolean
|
||||
) => `absolute top-0 z-1 text-xs inline-flex h-full items-center justify-center font-medium
|
||||
${
|
||||
isActive ? 'hover:text-foreground-light hover:text-foreground' : 'hover:text-foreground'
|
||||
} hover:text-foreground focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue
|
||||
transition ease-in-out duration-150`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative border ${borderOverride} rounded-md h-7`}
|
||||
style={{ padding: 1, width: (width + 1) * 2 }}
|
||||
>
|
||||
<span
|
||||
style={{ width, translate: activeOption === options[1] ? '0px' : `${width - 2}px` }}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'z-0 inline-block rounded h-full bg-overlay-hover shadow transform',
|
||||
'transition-all ease-in-out border border-strong'
|
||||
)}
|
||||
></span>
|
||||
{options.map((option: any, index: number) => (
|
||||
<span
|
||||
key={`toggle_${index}`}
|
||||
style={{ width: width + 1 }}
|
||||
className={`
|
||||
${activeOption === option ? 'text-foreground' : 'text-foreground-light'}
|
||||
${index === 0 ? 'right-0' : 'left-0'}
|
||||
${buttonStyle(activeOption === option)}
|
||||
cursor-pointer
|
||||
`}
|
||||
onClick={() => onClickOption(option)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'capitalize hover:text-foreground',
|
||||
activeOption === option ? 'text-foreground' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TwoOptionToggle
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type Accent = 'green'
|
||||
|
||||
export const AccentContext = createContext<Accent>('green')
|
||||
|
||||
export function useAccent() {
|
||||
return useContext(AccentContext)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateAICodingToolsSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH ai_coding_tools_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN technology IN (
|
||||
'Cursor',
|
||||
'Windsurf',
|
||||
'Cline',
|
||||
'Visual Studio Code',
|
||||
'Lovable',
|
||||
'Bolt',
|
||||
'v0',
|
||||
'Tempo',
|
||||
'None'
|
||||
) THEN technology
|
||||
WHEN LOWER(technology) LIKE '%claude%' THEN 'Claude or Claude Code'
|
||||
WHEN LOWER(technology) = 'chatgpt' THEN 'ChatGPT'
|
||||
ELSE 'Other'
|
||||
END AS technology_clean
|
||||
FROM (
|
||||
SELECT id, unnest(ai_coding_tools) AS technology
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
technology_clean AS technology,
|
||||
COUNT(DISTINCT id) AS total
|
||||
FROM ai_coding_tools_mapping
|
||||
GROUP BY technology_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function AICodingToolsChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Which AI coding tools do you use?"
|
||||
targetColumn="ai_coding_tools"
|
||||
filterColumns={['person_age', 'team_size', 'money_raised']}
|
||||
functionName="get_ai_coding_tools_stats"
|
||||
generateSQLQuery={generateAICodingToolsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateAIModelsSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH ai_models_used_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN technology IN (
|
||||
'OpenAI',
|
||||
'Anthropic/Claude',
|
||||
'Hugging Face',
|
||||
'Custom models',
|
||||
'SageMaker',
|
||||
'Bedrock',
|
||||
'Cohere',
|
||||
'Mistral'
|
||||
) THEN technology
|
||||
WHEN LOWER(technology) LIKE '%gemini%' THEN 'Gemini'
|
||||
WHEN LOWER(technology) = 'deepseek' THEN 'DeepSeek'
|
||||
ELSE 'Other'
|
||||
END AS technology_clean
|
||||
FROM (
|
||||
SELECT id, unnest(ai_models_used) AS technology
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
technology_clean AS technology,
|
||||
COUNT(DISTINCT id) AS total
|
||||
FROM ai_models_used_mapping
|
||||
GROUP BY technology_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function AIModelsChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Which AI models are you using or planning to use?"
|
||||
targetColumn="ai_models_used"
|
||||
filterColumns={['person_age', 'team_size', 'money_raised']}
|
||||
functionName="get_ai_models_stats"
|
||||
generateSQLQuery={generateAIModelsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateAcceleratorParticipationSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters, ['accelerator_participation IS NOT NULL'])
|
||||
|
||||
return `WITH accelerator_mapping AS (
|
||||
SELECT
|
||||
accelerator_participation,
|
||||
CASE
|
||||
WHEN accelerator_participation IN ('YC', 'Techstars', 'EF', '500 Global', 'Plug and Play', 'Antler') THEN accelerator_participation
|
||||
WHEN accelerator_participation = 'Did not participate in an accelerator' THEN NULL
|
||||
ELSE 'Other'
|
||||
END AS accelerator_clean
|
||||
FROM responses_2025
|
||||
${whereClause ? '\n ' + whereClause : ''}
|
||||
)
|
||||
SELECT
|
||||
accelerator_clean AS accelerator_participation,
|
||||
COUNT(*) AS total
|
||||
FROM accelerator_mapping
|
||||
WHERE accelerator_clean IS NOT NULL
|
||||
GROUP BY accelerator_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function AcceleratorParticipationChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="If your startup has participated in an accelerator, which one?"
|
||||
targetColumn="accelerator_participation"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_accelerator_stats"
|
||||
generateSQLQuery={generateAcceleratorParticipationSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateBiggestChallengeSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters, ['biggest_challenge IS NOT NULL'])
|
||||
|
||||
return `WITH biggest_challenge_mapping AS (
|
||||
SELECT
|
||||
biggest_challenge,
|
||||
CASE
|
||||
WHEN biggest_challenge IN ('Customer acquisition', 'Technical complexity', 'Product-market fit', 'Product-market fit', 'Fundraising', 'Hiring', 'Other') THEN biggest_challenge
|
||||
ELSE 'Other'
|
||||
END AS biggest_challenge_clean
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
)
|
||||
SELECT
|
||||
biggest_challenge_clean AS biggest_challenge,
|
||||
COUNT(*) AS total
|
||||
FROM biggest_challenge_mapping
|
||||
GROUP BY biggest_challenge_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function BiggestChallengeChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="What’s the biggest business challenge your startup is facing today?"
|
||||
targetColumn="biggest_challenge"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_biggest_challenge_stats"
|
||||
generateSQLQuery={generateBiggestChallengeSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateDatabasesSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH database_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN technology IN (
|
||||
'Supabase',
|
||||
'PostgreSQL',
|
||||
'MySQL',
|
||||
'MongoDB',
|
||||
'Redis',
|
||||
'Firebase',
|
||||
'SQLite'
|
||||
) THEN technology
|
||||
ELSE 'Other'
|
||||
END AS technology_clean
|
||||
FROM (
|
||||
SELECT id, unnest(databases) AS technology
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
technology_clean AS technology,
|
||||
COUNT(DISTINCT id) AS total
|
||||
FROM database_mapping
|
||||
GROUP BY technology_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function DatabasesChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Which database(s) is your startup using?"
|
||||
targetColumn="databases"
|
||||
filterColumns={['person_age', 'team_size', 'money_raised']}
|
||||
functionName="get_databases_stats"
|
||||
generateSQLQuery={generateDatabasesSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateFundingStageSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `SELECT
|
||||
funding_stage,
|
||||
COUNT(*) AS total
|
||||
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
|
||||
GROUP BY funding_stage
|
||||
ORDER BY CASE
|
||||
WHEN funding_stage = 'Bootstrapped' THEN 1
|
||||
WHEN funding_stage = 'Pre-seed' THEN 2
|
||||
WHEN funding_stage = 'Seed' THEN 3
|
||||
WHEN funding_stage = 'Series A' THEN 4
|
||||
WHEN funding_stage = 'Series B' THEN 5
|
||||
WHEN funding_stage = 'Series C' THEN 6
|
||||
WHEN funding_stage = 'Series D or later' THEN 7
|
||||
ELSE 8
|
||||
END;`
|
||||
}
|
||||
|
||||
export function FundingStageChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="What stage of funding is your startup in?"
|
||||
targetColumn="funding_stage"
|
||||
filterColumns={['person_age', 'location', 'team_size']}
|
||||
functionName="get_funding_stage_stats"
|
||||
generateSQLQuery={generateFundingStageSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateIndustrySQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters, ['industry IS NOT NULL'])
|
||||
|
||||
return `WITH industry_mapping AS (
|
||||
SELECT
|
||||
industry,
|
||||
CASE
|
||||
WHEN industry = 'Developer tools and platforms' THEN 'Dev tools'
|
||||
WHEN industry = 'AI / ML tools' THEN 'AI / ML'
|
||||
WHEN industry IN ('SaaS', 'Dev tools', 'AI / ML', 'Consumer', 'Education', 'eCommerce', 'Fintech', 'Healthtech') THEN industry
|
||||
ELSE 'Other'
|
||||
END AS industry_clean
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
)
|
||||
SELECT
|
||||
industry_clean AS industry,
|
||||
COUNT(*) AS total
|
||||
FROM industry_mapping
|
||||
GROUP BY industry_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function IndustryChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="What is your startup’s primary industry or target customer segment?"
|
||||
targetColumn="industry"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_industry_stats"
|
||||
generateSQLQuery={generateIndustrySQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateInitialPayingCustomersSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH customer_source_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN source IN (
|
||||
'Personal/professional network',
|
||||
'Inbound from social media (Twitter, LinkedIn, etc.)',
|
||||
'Cold outreach or sales',
|
||||
'Content (blog, newsletter, SEO)',
|
||||
'Developer communities (Discord, Slack, Reddit, etc.)',
|
||||
'Open source users who converted',
|
||||
'Accelerators/incubators',
|
||||
'Hacker News or Product Hunt'
|
||||
) THEN source
|
||||
ELSE 'Other'
|
||||
END AS source_clean
|
||||
FROM (
|
||||
SELECT id, unnest(initial_paying_customers) AS source
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
source_clean AS source,
|
||||
COUNT(DISTINCT id) AS respondents
|
||||
FROM customer_source_mapping
|
||||
GROUP BY source_clean
|
||||
ORDER BY respondents DESC;`
|
||||
}
|
||||
|
||||
export function InitialPayingCustomersChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Where did your startup’s initial paying customers come from?"
|
||||
targetColumn="initial_paying_customers"
|
||||
filterColumns={['person_age', 'location', 'team_size']}
|
||||
functionName="get_initial_paying_customers_stats"
|
||||
generateSQLQuery={generateInitialPayingCustomersSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateLocationSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `SELECT
|
||||
location,
|
||||
COUNT(*) AS total
|
||||
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
|
||||
GROUP BY location
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function LocationChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Where is your startup headquartered?"
|
||||
targetColumn="location"
|
||||
filterColumns={['person_age', 'team_size', 'money_raised']}
|
||||
functionName="get_location_stats"
|
||||
generateSQLQuery={generateLocationSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateNewIdeasSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH new_ideas_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN avenue IN (
|
||||
'Hacker News',
|
||||
'GitHub',
|
||||
'Product Hunt',
|
||||
'Twitter/X',
|
||||
'Reddit',
|
||||
'YouTube',
|
||||
'Podcasts',
|
||||
'Blogs / Newsletters',
|
||||
'Discord / Slack communities',
|
||||
'Conferences / Meetups'
|
||||
) THEN avenue
|
||||
ELSE 'Other'
|
||||
END AS avenue_clean
|
||||
FROM (
|
||||
SELECT id, unnest(new_ideas) AS avenue
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
avenue_clean AS avenue,
|
||||
COUNT(DISTINCT id) AS total
|
||||
FROM new_ideas_mapping
|
||||
GROUP BY avenue_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function NewIdeasChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Where do you usually discover new dev tools or startup ideas?"
|
||||
targetColumn="new_ideas"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_new_ideas_stats"
|
||||
generateSQLQuery={generateNewIdeasSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateRegularSocialMediaUseSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH regular_social_media_use_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN platform IN (
|
||||
'X (Twitter)',
|
||||
'Threads',
|
||||
'BlueSky',
|
||||
'LinkedIn',
|
||||
'Reddit',
|
||||
'TikTok',
|
||||
'Instagram',
|
||||
'YouTube',
|
||||
'Mastodon',
|
||||
'Discord',
|
||||
'I’ve given up social media'
|
||||
) THEN platform
|
||||
ELSE 'Other'
|
||||
END AS platform_clean
|
||||
FROM (
|
||||
SELECT id, unnest(regular_social_media_use) AS platform
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
platform_clean AS platform,
|
||||
COUNT(DISTINCT id) AS total
|
||||
FROM regular_social_media_use_mapping
|
||||
GROUP BY platform_clean
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function RegularSocialMediaUseChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Which social media platforms do you use at least 3× per week?"
|
||||
targetColumn="regular_social_media_use"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_regular_social_media_use_stats"
|
||||
generateSQLQuery={generateRegularSocialMediaUseSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateRoleSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `SELECT
|
||||
CASE
|
||||
WHEN role = 'Founder / Co-founder' THEN 'Founder'
|
||||
WHEN role IN ('Engineer', 'Founder / Co-founder') THEN role
|
||||
ELSE 'Other'
|
||||
END AS role,
|
||||
COUNT(*) AS total
|
||||
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
|
||||
GROUP BY CASE
|
||||
WHEN role = 'Founder / Co-founder' THEN 'Founder'
|
||||
WHEN role IN ('Engineer', 'Founder / Co-founder') THEN role
|
||||
ELSE 'Other'
|
||||
END
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function RoleChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="What is your functional role at your startup?"
|
||||
targetColumn="role"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_role_stats"
|
||||
generateSQLQuery={generateRoleSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateSalesToolsSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `WITH customer_tool_mapping AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN tool IN (
|
||||
'HubSpot',
|
||||
'Salesforce',
|
||||
'Pipedrive',
|
||||
'Close.com',
|
||||
'Notion / Airtable',
|
||||
'Google Sheets',
|
||||
'We don’t have a formal CRM or sales tool yet'
|
||||
) THEN tool
|
||||
ELSE 'Other'
|
||||
END AS tool_clean
|
||||
FROM (
|
||||
SELECT id, unnest(sales_tools) AS tool
|
||||
FROM responses_2025
|
||||
${whereClause}
|
||||
) sub
|
||||
)
|
||||
SELECT
|
||||
tool_clean AS tool,
|
||||
COUNT(DISTINCT id) AS respondents
|
||||
FROM customer_tool_mapping
|
||||
GROUP BY tool_clean
|
||||
ORDER BY respondents DESC;`
|
||||
}
|
||||
|
||||
export function SalesToolsChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="What tools are you using to manage your sales process?"
|
||||
targetColumn="sales_tools"
|
||||
filterColumns={['person_age', 'location', 'team_size']}
|
||||
functionName="get_sales_tools_stats"
|
||||
generateSQLQuery={generateSalesToolsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SurveyChart, buildWhereClause } from '../SurveyChart'
|
||||
|
||||
function generateWorldOutlookSQL(activeFilters: Record<string, string>) {
|
||||
const whereClause = buildWhereClause(activeFilters)
|
||||
|
||||
return `SELECT
|
||||
world_outlook,
|
||||
COUNT(*) AS total
|
||||
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
|
||||
GROUP BY world_outlook
|
||||
ORDER BY total DESC;`
|
||||
}
|
||||
|
||||
export function WorldOutlookChart() {
|
||||
return (
|
||||
<SurveyChart
|
||||
title="Given the state of the world, are you…"
|
||||
targetColumn="world_outlook"
|
||||
filterColumns={['person_age', 'location', 'money_raised']}
|
||||
functionName="get_world_outlook_stats"
|
||||
generateSQLQuery={generateWorldOutlookSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@keyframes terminalLine {
|
||||
0% {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
20% {
|
||||
clip-path: inset(0 80% 0 0);
|
||||
}
|
||||
50% {
|
||||
clip-path: inset(0 40% 0 0);
|
||||
}
|
||||
70% {
|
||||
clip-path: inset(0 20% 0 0);
|
||||
}
|
||||
80% {
|
||||
clip-path: inset(0 0% 0 0);
|
||||
}
|
||||
90% {
|
||||
clip-path: inset(0 0 0 50%);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(0 0 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import localFont from 'next/font/local'
|
||||
|
||||
const suisseIntl = localFont({
|
||||
src: [
|
||||
{
|
||||
path: '../../public/fonts/state-of-startups/SuisseIntl-Book.otf',
|
||||
weight: '450',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/fonts/state-of-startups/SuisseIntl-Regular.otf',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/fonts/state-of-startups/SuisseIntl-Medium.otf',
|
||||
weight: '500',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/fonts/state-of-startups/SuisseIntl-Bold.otf',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
variable: '--font-suisse-intl',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export default function StateOfStartupsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={suisseIntl.variable} style={{ fontFamily: 'var(--font-suisse-intl)' }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { RegisterContent } from './register/RegisterContent'
|
||||
import StateOfStartups2026Content from './StateOfStartups2026Content'
|
||||
|
||||
// TODO (alan): set this to true once results are available.
|
||||
const SHOW_RESULTS = false
|
||||
|
||||
export const metadata: Metadata = SHOW_RESULTS
|
||||
? {
|
||||
title: 'State of Startups 2026 | Supabase',
|
||||
description:
|
||||
'The latest trends among builders in tech stacks, AI usage, problem domains, and more.',
|
||||
openGraph: {
|
||||
title: 'State of Startups 2026 | Supabase',
|
||||
description:
|
||||
'The latest trends among builders in tech stacks, AI usage, problem domains, and more.',
|
||||
url: 'https://supabase.com/state-of-startups',
|
||||
images: [
|
||||
{
|
||||
url: 'https://supabase.com/images/state-of-startups/2026/state-of-startups-og.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {
|
||||
title: 'State of Startups 2026 — Survey | Supabase',
|
||||
description: 'Be the first to access the State of Startups 2026 report.',
|
||||
}
|
||||
|
||||
export default function StateOfStartupsPage() {
|
||||
return SHOW_RESULTS ? <StateOfStartups2026Content /> : <RegisterContent />
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import UnicornScene from 'unicornstudio-react/next'
|
||||
|
||||
import { DecorativeProgressBar } from '../components/DecorativeProgressBar'
|
||||
import { SurveySectionBreak } from '../components/SurveySectionBreak'
|
||||
|
||||
import '../components/surveyResults.css'
|
||||
|
||||
import shirtImage from './shirt.png'
|
||||
|
||||
const Footer = dynamic(() => import('components/Footer/index'))
|
||||
|
||||
export function RegisterContent() {
|
||||
const [shaderLoaded, setShaderLoaded] = useState(false)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
return (
|
||||
<main
|
||||
className="relative w-full overflow-hidden flex flex-col"
|
||||
style={{ background: 'hsl(var(--background-alternative-default))' }}
|
||||
>
|
||||
<div>
|
||||
<header
|
||||
className="relative w-full overflow-hidden"
|
||||
style={{ minHeight: '60vh', background: 'hsl(var(--background-alternative-default))' }}
|
||||
>
|
||||
{/* Aurora shader scene — hidden when user prefers reduced motion */}
|
||||
{!prefersReducedMotion && (
|
||||
<div aria-hidden="true" className="absolute inset-0">
|
||||
<UnicornScene
|
||||
jsonFilePath={'/images/state-of-startups/aurora-dithered-shader.json'}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full"
|
||||
dpi={1.5}
|
||||
fps={24}
|
||||
onLoad={() => setShaderLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder — visible until shader loads, fades out on load */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none transition-opacity [transition-duration:1200ms]"
|
||||
style={{
|
||||
opacity: shaderLoaded ? 0 : 1,
|
||||
background: 'hsl(var(--background-alternative-default))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, hsl(var(--background-alternative-default)) 0%, hsl(var(--background-alternative-default) / 0.9) 15%, transparent 90%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content wrapper — full height, flex col, nav top + content bottom */}
|
||||
<div className="relative z-10 flex flex-col" style={{ minHeight: '60vh' }}>
|
||||
{/* Top nav */}
|
||||
<nav className="flex items-center justify-between px-8 py-6 w-full">
|
||||
<Link
|
||||
href="/state-of-startups"
|
||||
className="font-mono uppercase tracking-wide text-sm text-white"
|
||||
>
|
||||
State of Startups 2026
|
||||
</Link>
|
||||
<Link href="/" className="text-white transition-colors ">
|
||||
supabase.com
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 hidden md:block" />
|
||||
|
||||
{/* Bottom content — two columns, bottom-aligned */}
|
||||
<div className="max-w-[65rem] mx-auto w-full px-8 pb-16 md:pb-24 pt-24 md:pt-0 grid grid-cols-1 lg:grid-cols-[auto_1fr] items-end gap-12 lg:gap-20 transition-opacity [transition-duration:1200ms]">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-6xl md:text-8xl font-light text-foreground leading-[0.92] tracking-tight">
|
||||
The State <br /> of Startups
|
||||
<br />
|
||||
<span className="text-brand-600 dark:text-brand font-medium">2026</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right: description + CTA */}
|
||||
<div className="flex flex-col items-start gap-6 max-w-[460px] md:justify-self-end">
|
||||
<p className="text-foreground-light text-lg md:text-pretty">
|
||||
There's never been a better time to build. AI tools, smaller teams, faster product
|
||||
cycles.
|
||||
</p>
|
||||
|
||||
<p className="text-foreground-light text-lg md:text-pretty">
|
||||
Over 2000 people took the survey in 2025. But a lot has changed in the last year.
|
||||
We want to know what building at startups looks like from where you sit.
|
||||
</p>
|
||||
|
||||
<Button asChild size="medium" iconRight={<ArrowRight size={12} />}>
|
||||
<Link href="https://supabase.typeform.com/to/p2XiROl8" target="_blank">
|
||||
Take the Survey
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* About section */}
|
||||
<SurveySectionBreak />
|
||||
<section className="w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 max-w-[60rem] mx-auto md:border-x border-muted">
|
||||
{/* Left: description text */}
|
||||
<div className="md:col-span-2 flex flex-col gap-4 px-8 py-10 border-b md:border-b-0 md:border-r border-muted text-foreground text-xl md:text-2xl text-balance">
|
||||
<p>
|
||||
This is a 7-minute survey. It covers AI adoption, tech stack, GTM, and the challenges
|
||||
startups are actually facing. We'll publish the full results.
|
||||
</p>
|
||||
<p>And for your trouble, we'll send you a free Supabase t-shirt.</p>
|
||||
</div>
|
||||
|
||||
<ShirtImage />
|
||||
</div>
|
||||
<SurveySectionBreak />
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ShirtImage() {
|
||||
const [hasAppeared, setHasAppeared] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center relative overflow-hidden aspect-[1/1]">
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{
|
||||
scale: { type: 'spring', stiffness: 260, damping: 14, delay: 1 },
|
||||
opacity: { type: 'spring', stiffness: 100, damping: 20, delay: 1 },
|
||||
}}
|
||||
onAnimationComplete={() => setHasAppeared(true)}
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full h-full"
|
||||
animate={hasAppeared ? { rotate: [0, -3, 3, -2, 1, 0] } : undefined}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
repeatDelay: 4,
|
||||
delay: 2,
|
||||
}}
|
||||
>
|
||||
<Image src={shirtImage} fill alt="Supabase Shirt" className="object-cover" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Button } from 'ui'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import AnnouncementBadge from 'components/Announcement/Badge'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
const Hero = () => {
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
@@ -14,6 +14,11 @@ const Hero = () => {
|
||||
<div className="mx-auto">
|
||||
<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">
|
||||
<AnnouncementBadge
|
||||
url="/state-of-startups"
|
||||
announcement="State of Startups 2026: Take the survey."
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<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>
|
||||
|
||||
@@ -25,7 +25,7 @@ const GitHubButton = () => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="hidden group lg:flex text-foreground-light hover:text-foreground"
|
||||
className="hidden group lg:flex text-foreground-light hover:text-foreground [[data-nav-transparent]_&]:text-foreground"
|
||||
type="text"
|
||||
asChild
|
||||
onClick={() => sendTelemetryEvent({ action: 'homepage_github_button_clicked' })}
|
||||
|
||||
@@ -59,6 +59,7 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
|
||||
const isLaunchWeek12Page = pathname === '/launch-week/12'
|
||||
const isLaunchWeek13Page = pathname === '/launch-week/13'
|
||||
const isGAWeekSection = pathname?.startsWith('/ga-week')
|
||||
const isStateOfStartupsPage = pathname?.startsWith('/state-of-startups')
|
||||
const disableStickyNav =
|
||||
isLaunchWeekXPage ||
|
||||
isGAWeekSection ||
|
||||
@@ -68,6 +69,16 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
|
||||
!stickyNavbar
|
||||
const showLaunchWeekNavMode = (isGAWeekSection || isLaunchWeekXPage) && !open
|
||||
|
||||
const [scrolled, setScrolled] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
if (!isStateOfStartupsPage) return
|
||||
const handleScroll = () => setScrolled(window.scrollY > 50)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isStateOfStartupsPage])
|
||||
|
||||
const isTransparent = isStateOfStartupsPage && !scrolled && !open
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Prevent scrolling on mount
|
||||
@@ -89,21 +100,28 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('sticky top-0 z-40 transform', disableStickyNav && 'relative')}
|
||||
className={cn(
|
||||
'sticky top-0 z-40 transform',
|
||||
disableStickyNav && 'relative',
|
||||
isStateOfStartupsPage && 'fixed left-0 right-0'
|
||||
)}
|
||||
style={{ transform: 'translate3d(0,0,999px)' }}
|
||||
data-nav-transparent={isTransparent ? '' : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 h-full w-full bg-background/90 dark:bg-background/95',
|
||||
!showLaunchWeekNavMode && '!opacity-100 transition-opacity',
|
||||
showLaunchWeekNavMode && '!bg-transparent dark:!bg-black transition-all',
|
||||
isGAWeekSection && 'dark:!bg-alternative'
|
||||
'absolute inset-0 h-full w-full bg-background/90 dark:bg-background/95 transition-all duration-300',
|
||||
!showLaunchWeekNavMode && !isTransparent && '!opacity-100',
|
||||
showLaunchWeekNavMode && '!bg-transparent dark:!bg-black',
|
||||
isGAWeekSection && 'dark:!bg-alternative',
|
||||
isTransparent && '!bg-transparent dark:!bg-transparent !opacity-100'
|
||||
)}
|
||||
/>
|
||||
<nav
|
||||
className={cn(
|
||||
`relative z-40 border-default border-b backdrop-blur-sm transition-opacity`,
|
||||
showLaunchWeekNavMode && 'border-muted border-b bg-transparent'
|
||||
`relative z-40 border-default border-b backdrop-blur-sm transition-all duration-300`,
|
||||
showLaunchWeekNavMode && 'border-muted border-b bg-transparent',
|
||||
isTransparent && 'border-transparent backdrop-blur-none'
|
||||
)}
|
||||
>
|
||||
<div className="relative flex justify-between h-16 mx-auto lg:container lg:px-16 xl:px-20">
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
const stateOfStartupsData = {
|
||||
metaTitle: 'State of Startups 2026 | Supabase',
|
||||
metaDescription:
|
||||
'The latest trends among builders in tech stacks, AI usage, problem domains, and more.',
|
||||
metaImage: '/images/state-of-startups/2026/state-of-startups-og.png',
|
||||
heroSection: {
|
||||
title: 'State of Startups 2026',
|
||||
subheader:
|
||||
'We surveyed over 2,000 startup founders and builders to uncover what’s powering modern startups: their stacks, their go-to-market motion, and their approach to AI.',
|
||||
cta: 'This report is built for builders.',
|
||||
},
|
||||
pageChapters: [
|
||||
{
|
||||
title: 'Who’s Building Startups',
|
||||
shortTitle: 'Founder and Company',
|
||||
description:
|
||||
'Today’s startup ecosystem is dominated by young, technical builders shipping fast with lean teams.',
|
||||
pullQuote: {
|
||||
quote:
|
||||
'Our team is just two people at the moment. We’re funding the proof-of-concept stage out of our own pockets.',
|
||||
author: 'Richard Kranendonk',
|
||||
authorPosition: 'CEO, Thinking Security Works',
|
||||
authorAvatar: '/images/state-of-startups/quote-avatars/richard-k-120x120.jpg',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
title: 'Roles and Experience',
|
||||
description:
|
||||
'Founders are overwhelmingly technical and under 40, with most building their first company.',
|
||||
stats: [
|
||||
{ percent: 81, label: 'Founders that are technical' },
|
||||
{ percent: 82, label: 'Founders that are under 40' },
|
||||
{ percent: 36, label: 'Founders that are repeat founders' },
|
||||
],
|
||||
charts: ['RoleChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Team Size and Funding',
|
||||
description:
|
||||
'Startups are mostly bootstrapped or at early stages of funding. They are small teams, and usually less than a year old.',
|
||||
stats: [
|
||||
{ percent: 91, label: 'Startups with 10 or fewer employees' },
|
||||
{ percent: 66, label: 'Startups under one year old' },
|
||||
{ percent: 6, label: 'Startups over 5 years old' },
|
||||
],
|
||||
charts: ['FundingStageChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Where They’re Based',
|
||||
description:
|
||||
'Startups are building globally, but North America—especially San Francisco—remains overrepresented. Europe and Asia also feature prominently, with hubs like Toronto and NYC following close behind.',
|
||||
stats: [
|
||||
{ percent: 25, label: 'Global startups based in Europe' },
|
||||
{ percent: 19, label: 'North American startups based in San Francisco' },
|
||||
{ percent: 9, label: 'North American startups based in New York City' },
|
||||
],
|
||||
charts: ['LocationChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'What Startups are Building',
|
||||
shortTitle: 'Product and Market',
|
||||
description:
|
||||
'Startups are still experimenting. They’re building a diverse mix of software products, iterating quickly, and pursuing monetization selectively.',
|
||||
pullQuote: {
|
||||
quote:
|
||||
'We’re building an end-to-end system for wedding planners, all running as one SvelteKit / Supabase instance.',
|
||||
author: 'Waldemar Pross',
|
||||
authorPosition: 'CTO, Peach Perfect Weddings',
|
||||
authorAvatar: '/images/state-of-startups/quote-avatars/waldemar-k-120x120.jpg',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
title: 'Industries and Focus',
|
||||
description:
|
||||
'Under-30s gravitate toward AI-driven productivity, education, and social tools; areas where rapid iteration and novelty matter. Over-50s skew toward SaaS and consumer products, often bringing domain-specific experience into more established markets. Developer tools and infrastructure attract all age groups.',
|
||||
stats: [
|
||||
{ percent: 82, label: 'Founders under 30 building in AI/ML' },
|
||||
{ percent: 60, label: 'Startups building for end consumers' },
|
||||
{ percent: 16, label: 'Startups building for developers' },
|
||||
],
|
||||
charts: ['IndustryChart'],
|
||||
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: {
|
||||
label: 'Problems startups are solving',
|
||||
answers: [
|
||||
'AI-powered productivity tools',
|
||||
'Agent workflows (internal or customer-facing)',
|
||||
'Career preparation and job search',
|
||||
'AI copilots for small businesses',
|
||||
'AI-enhanced education and tutoring',
|
||||
'Tools for solopreneurs and creators',
|
||||
'Developer experience and API abstraction',
|
||||
'Sales and outreach automation',
|
||||
'Healthcare access and diagnostics',
|
||||
'Financial planning and forecasting',
|
||||
'Sustainability and climate data',
|
||||
'Privacy and compliance automation',
|
||||
'Time management and prioritization',
|
||||
'Collaboration and communication',
|
||||
'Mental health and wellness tracking',
|
||||
],
|
||||
},
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Traction and Early Growth',
|
||||
description:
|
||||
'One in five startups joined an accelerator. Y Combinator is the most common choice, especially in North America. Elsewhere, participation was more evenly distributed. Pivoting remains the norm, and less than half of startups are monetizing today.',
|
||||
stats: [
|
||||
{ percent: 64, label: 'Startups that are pre-revenue' },
|
||||
{ percent: 59, label: 'Startups that pivoted at least once' },
|
||||
{ percent: 19, label: 'Startups that joined accelerators' },
|
||||
],
|
||||
charts: ['AcceleratorParticipationChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'What’s in a Startup’s Tech Stack',
|
||||
shortTitle: 'Tech Stack',
|
||||
description:
|
||||
'The modern stack centers around open tools, modular infrastructure, and cautious spending.',
|
||||
pullQuote: {
|
||||
quote:
|
||||
'Cursor has been my favourite tool so far. It’s made my life easier by documenting code on my behalf.',
|
||||
author: 'Kevinton B',
|
||||
authorPosition: 'Engineer, FlutterFlow',
|
||||
authorAvatar: '/images/state-of-startups/quote-avatars/kevinton-b-120x120.jpg',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
title: 'Frameworks and Cloud Infra',
|
||||
description:
|
||||
'Supabase and Postgres dominate backend infrastructure. React and Node top frontend and backend respectively. Cursor, Claude, and VS Code lead AI-assisted development. Developer tools like GitHub, Stripe, and Postman round out the stack.',
|
||||
stats: [
|
||||
{
|
||||
percent: 83,
|
||||
label: 'Startups with a JavaScript framework in their frontend stack',
|
||||
},
|
||||
{
|
||||
percent: 62,
|
||||
label: 'Startups with Supabase in their cloud provider stack',
|
||||
},
|
||||
{ percent: 60, label: 'Startups with Node.js in their backend stack' },
|
||||
],
|
||||
charts: ['DatabasesChart'],
|
||||
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Dev Tools and Time Savers',
|
||||
description:
|
||||
'AI coding tools are indispensable for startups, and not just Cursor and Visual Studio Code. ‘Vibe coding’ tools like Loveable, Bolt.new, and v0 are also common.',
|
||||
stats: [
|
||||
{ percent: 57, label: 'Startups that pay for OpenAI or ChatGPT' },
|
||||
{ percent: 37, label: 'Startups that pay for Cursor' },
|
||||
{ percent: 12, label: 'Startups that don’t pay for AI tools at all' },
|
||||
],
|
||||
charts: ['AICodingToolsChart'],
|
||||
wordCloud: {
|
||||
label: 'Must-have developer tools by keyword frequency',
|
||||
words: [
|
||||
{ text: 'cursor', count: 495 },
|
||||
{ text: 'code', count: 396 },
|
||||
{ text: 'supabase', count: 302 },
|
||||
{ text: 'github', count: 272 },
|
||||
{ text: 'vscode', count: 160 },
|
||||
{ text: 'claude', count: 143 },
|
||||
{ text: 'docker', count: 114 },
|
||||
{ text: 'git', count: 113 },
|
||||
{ text: 'studio', count: 112 },
|
||||
{ text: 'chatgpt', count: 92 },
|
||||
{ text: 'postman', count: 89 },
|
||||
{ text: 'copilot', count: 88 },
|
||||
{ text: 'lovable', count: 83 },
|
||||
{ text: 'visual', count: 76 },
|
||||
{ text: 'vercel', count: 71 },
|
||||
{ text: 'react', count: 70 },
|
||||
{ text: 'figma', count: 68 },
|
||||
{ text: 'ide', count: 58 },
|
||||
{ text: 'windsurf', count: 51 },
|
||||
{ text: 'backend', count: 44 },
|
||||
{ text: 'api', count: 40 },
|
||||
{ text: 'google', count: 37 },
|
||||
{ text: 'testing', count: 36 },
|
||||
{ text: 'tailwind', count: 35 },
|
||||
{ text: 'chrome', count: 34 },
|
||||
{ text: 'typescript', count: 33 },
|
||||
{ text: 'control', count: 33 },
|
||||
{ text: 'python', count: 32 },
|
||||
{ text: 'gemini', count: 30 },
|
||||
],
|
||||
},
|
||||
summarizedAnswer: {
|
||||
label: 'Tools that startups wish existed',
|
||||
answers: [
|
||||
'Unified backend platform combining auth, edge, database, and queues',
|
||||
'AI agents with real memory and workflow context',
|
||||
'Local-first dev environments that sync to Supabase or Git',
|
||||
'AI copilots for sales, marketing, or documentation',
|
||||
'UI builders with direct-to-code export and stateful logic',
|
||||
'Better CLI-driven or REPL-native dev tools',
|
||||
'Automated integration layers between SaaS APIs',
|
||||
'Real-time dashboards that don’t require BI tools',
|
||||
'Supabase + Neon or PlanetScale seamless sync',
|
||||
'One-click staging, testing, and preview environments',
|
||||
'AI validators for production database migrations',
|
||||
'Visual version control and state inspection for app logic',
|
||||
'Time-aware tools (versioned environments, snapshots, undoable infra)',
|
||||
'GPT-based toolchain composers (meta-dev agents)',
|
||||
'Agent-like task runners for cron jobs, workflows, monitoring',
|
||||
],
|
||||
},
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'How Startups are Integrating AI',
|
||||
shortTitle: 'AI and Agents',
|
||||
description:
|
||||
'AI is a core product capability, not an afterthought. Most teams are using models like OpenAI or Claude for real features, not just demos.',
|
||||
pullQuote: {
|
||||
quote:
|
||||
'AI is embedded in how we build and scale. From using Claude and Cursor in dev, to voice AI in product for smarter, faster recruiting (which is our business).',
|
||||
author: 'Jinal Jhaveri',
|
||||
authorPosition: 'Founder, Mismo',
|
||||
authorAvatar: '/images/state-of-startups/quote-avatars/jinal-j-120x120.jpg',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
title: 'In-Product AI Use',
|
||||
description:
|
||||
'Most startups are already integrating models like OpenAI or Claude, especially for semantic search, summarisation, and customer support. Half are building agents to automate real tasks, from onboarding flows to sales triage.',
|
||||
stats: [
|
||||
{
|
||||
percent: 81,
|
||||
label: 'Startups using AI in their product',
|
||||
},
|
||||
{ percent: 50, label: 'Startups building agents within their product' },
|
||||
{
|
||||
percent: 34,
|
||||
label: 'Startups with agents automating customer support',
|
||||
},
|
||||
],
|
||||
charts: ['AIModelsChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: {
|
||||
label: 'Most important AI use cases in product',
|
||||
answers: [
|
||||
'Summarization / content generation',
|
||||
'Recommendations / personalization',
|
||||
'Workflow / agent-based automation',
|
||||
'Search / semantic search',
|
||||
'Customer support automation',
|
||||
],
|
||||
},
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Where Startups Go to Learn',
|
||||
shortTitle: 'Influence',
|
||||
description: 'Online communities are the learning engine behind every early-stage startup.',
|
||||
pullQuote: undefined,
|
||||
sections: [
|
||||
{
|
||||
title: 'Online Communities',
|
||||
description:
|
||||
'There is a healthy diaspora of important online communities. That said, many people just lurk; few actively contribute to the discussion.',
|
||||
stats: [
|
||||
{ percent: 55, label: 'Engineers using LinkedIn regularly' },
|
||||
{ percent: 45, label: 'Founders using X (Twitter) regularly' },
|
||||
{ percent: 7, label: 'Respondents that don’t use social media at all' },
|
||||
],
|
||||
charts: ['RegularSocialMediaUseChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Inspiration Stack',
|
||||
description:
|
||||
'Founders follow newsletters like TLDR and Lenny’s, and they listen to podcasts like The Diary of a CEO and Founders. Tool discovery happens quite often via YouTube or GitHub. Physical event participation remains low.',
|
||||
stats: [
|
||||
{ percent: 47, label: 'Respondents that listen to industry podcasts' },
|
||||
{
|
||||
percent: 20,
|
||||
label: 'Respondents that subscribe to industry newsletters',
|
||||
},
|
||||
{ percent: 12, label: 'Founders that have built a developer community' },
|
||||
],
|
||||
charts: ['NewIdeasChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: [
|
||||
{
|
||||
label: 'Top podcasts listened to',
|
||||
answers: ['The Diary of a CEO', 'Founders', 'My First Million'],
|
||||
},
|
||||
{
|
||||
label: 'Top newsletters subscribed to',
|
||||
answers: ['TLDR', 'Lenny’s Newsletter', 'The Pragmatic Engineer'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'How Startups are Finding Customers',
|
||||
shortTitle: 'Go-To-Market',
|
||||
description:
|
||||
'Startups start selling through their networks and dev communities. Only when they grow do they layer in more structured growth via CRMs and sales.',
|
||||
pullQuote: undefined,
|
||||
sections: [
|
||||
{
|
||||
title: 'Initial Customers',
|
||||
description:
|
||||
'Founders earn their earliest customers through networks, communities, and inbound content. Paid acquisition rarely works early on, nor does performance marketing.',
|
||||
stats: [
|
||||
{
|
||||
percent: 58,
|
||||
label:
|
||||
'Startups that get their first customers via personal and professional networks',
|
||||
},
|
||||
{ percent: 48, label: 'Startups that engage users through social media' },
|
||||
{ percent: 39, label: 'Startups that are still experimenting with pricing models' },
|
||||
],
|
||||
charts: ['InitialPayingCustomersChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Founder-led Sales',
|
||||
description:
|
||||
'Sales is still founder-led at most startups. Dedicated sales hires usually don’t arrive until after 10+ employees. Many still use Google Sheets or nothing at all to track sales activity.',
|
||||
stats: [
|
||||
{
|
||||
percent: 75,
|
||||
label: 'Startups with their founders still directly responsible for sales',
|
||||
},
|
||||
{
|
||||
percent: 58,
|
||||
label: 'Startups that got their initial customers from personal networks',
|
||||
},
|
||||
{ percent: 7, label: 'Startups that have a dedicated sales team' },
|
||||
],
|
||||
charts: ['SalesToolsChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Biggest Challenges for Startups',
|
||||
shortTitle: 'Outlook',
|
||||
description:
|
||||
'Startups remain optimistic about the future but are weighed down by technical complexity, customer acquisition hurdles, and a wish list of tools that still don’t exist.',
|
||||
pullQuote: {
|
||||
quote:
|
||||
'There’s plenty of uncertainty, but we’re building something that feels deeply worth it. That gives us a lot of confidence in the long run.',
|
||||
author: 'Robert Wolski',
|
||||
authorPosition: 'Founder, Keepsake',
|
||||
authorAvatar: '/images/state-of-startups/quote-avatars/robert-w-120x120.jpg',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
title: 'The Road Ahead',
|
||||
description:
|
||||
'The hardest problems are still the oldest ones: customer acquisition, product-market fit, and complexity. Startups cite AI-assisted coding and backend services as major time-savers, but many are still missing critical tools they want. Especially around onboarding, dashboards, and agents.',
|
||||
stats: [
|
||||
{
|
||||
percent: 82,
|
||||
label: 'Founders that evaluate tools via hands-on experience',
|
||||
},
|
||||
{
|
||||
percent: 45,
|
||||
label:
|
||||
'Startups with over 250 employees whose biggest challenge is getting customers',
|
||||
},
|
||||
{
|
||||
percent: 4,
|
||||
label: 'Startups with under 10 employees whose biggest challenge is hiring',
|
||||
},
|
||||
],
|
||||
charts: ['BiggestChallengeChart'],
|
||||
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Worldview and Optimism',
|
||||
description:
|
||||
'Most startup founders remain upbeat about the future, but that confidence isn’t shared equally. Engineers and marketers show more caution.',
|
||||
|
||||
stats: [
|
||||
{ percent: 61, label: 'Founders that are optimistic' },
|
||||
{ percent: 50, label: 'Engineers that are optimistic' },
|
||||
{ percent: 42, label: 'Other roles that are optimistic' },
|
||||
],
|
||||
charts: ['WorldOutlookChart'],
|
||||
wordCloud: undefined,
|
||||
summarizedAnswer: undefined,
|
||||
rankedAnswersPair: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
participantsList: [
|
||||
{
|
||||
company: 'Wasp',
|
||||
url: 'https://wasp.sh/',
|
||||
},
|
||||
{
|
||||
company: 'Greptile',
|
||||
url: 'https://www.greptile.com/',
|
||||
},
|
||||
{
|
||||
company: 'Zaymo',
|
||||
url: 'https://zaymo.com',
|
||||
},
|
||||
{
|
||||
company: 'Jazzberry',
|
||||
url: 'https://jazzberry.ai',
|
||||
},
|
||||
{
|
||||
company: 'Shor',
|
||||
url: 'https://tryshor.com',
|
||||
},
|
||||
|
||||
{
|
||||
company: 'Mono',
|
||||
url: 'https://www.mono.la',
|
||||
},
|
||||
{
|
||||
company: 'Affl.ai',
|
||||
url: 'https://www.affil.ai',
|
||||
},
|
||||
{
|
||||
company: 'Docsum',
|
||||
url: 'https://www.docsum.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Hazel',
|
||||
url: 'https://hazelai.com/',
|
||||
},
|
||||
{
|
||||
company: 'Rivet',
|
||||
url: 'https://rivet.gg/',
|
||||
},
|
||||
{
|
||||
company: 'Trieve',
|
||||
url: 'https://trieve.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Artificial Societies',
|
||||
url: 'https://societies.io/',
|
||||
},
|
||||
{
|
||||
company: 'Gauge',
|
||||
url: 'https://withgauge.com/',
|
||||
},
|
||||
{
|
||||
company: 'Stardex',
|
||||
url: 'https://www.stardex.com/',
|
||||
},
|
||||
{
|
||||
company: 'TrueClaim',
|
||||
url: 'https://www.trytrueclaim.com/',
|
||||
},
|
||||
{
|
||||
company: 'Autosana',
|
||||
url: 'https://autosana.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Vespper',
|
||||
url: 'https://vespper.com/',
|
||||
},
|
||||
{
|
||||
company: 'Curo',
|
||||
url: 'https://www.curocharging.com/',
|
||||
},
|
||||
{
|
||||
company: 'Kombo',
|
||||
url: 'https://kombo.dev/',
|
||||
},
|
||||
{
|
||||
company: 'Candle',
|
||||
url: 'https://www.trycandle.app/',
|
||||
},
|
||||
{
|
||||
company: 'Trainloop',
|
||||
url: 'http://trainloop.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Replit',
|
||||
url: 'https://replit.com/',
|
||||
},
|
||||
{
|
||||
company: 'Roe AI',
|
||||
url: 'https://getroe.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Kestral',
|
||||
url: 'https://kestral.team/',
|
||||
},
|
||||
{
|
||||
company: 'Revyl',
|
||||
url: 'https://www.revyl.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Arva AI',
|
||||
url: 'https://www.arva.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Posthog',
|
||||
url: 'https://www.posthog.com',
|
||||
},
|
||||
{
|
||||
company: 'Rootly',
|
||||
url: 'https://rootly.com/',
|
||||
},
|
||||
{
|
||||
company: 'Throxy',
|
||||
url: 'https://throxy.com/',
|
||||
},
|
||||
{
|
||||
company: 'Zapi',
|
||||
url: 'https://heyzapi.com/',
|
||||
},
|
||||
{
|
||||
company: 'Leaping AI',
|
||||
url: 'https://www.leapingai.com/',
|
||||
},
|
||||
{
|
||||
company: 'WarpBuild',
|
||||
url: 'https://www.warpbuild.com/',
|
||||
},
|
||||
{
|
||||
company: 'Domu',
|
||||
url: 'https://www.domu.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Bilanc',
|
||||
url: 'https://www.bilanc.co/',
|
||||
},
|
||||
{
|
||||
company: 'Miru',
|
||||
url: 'https://www.miruml.com/',
|
||||
},
|
||||
{
|
||||
company: 'Repaint',
|
||||
url: 'https://repaint.com/',
|
||||
},
|
||||
{
|
||||
company: 'Cubic',
|
||||
url: 'https://cubic.dev/',
|
||||
},
|
||||
{
|
||||
company: 'CTGT',
|
||||
url: 'https://www.ctgt.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Integrated Reasoning',
|
||||
url: 'https://integrated-reasoning.com/',
|
||||
},
|
||||
{
|
||||
company: 'Datafruit',
|
||||
url: 'https://datafruit.dev/',
|
||||
},
|
||||
{
|
||||
company: 'mcp-use',
|
||||
url: 'https://mcp-use.com/',
|
||||
},
|
||||
{
|
||||
company: 'Weave',
|
||||
url: 'http://getweave.com/',
|
||||
},
|
||||
{
|
||||
company: 'Palmier',
|
||||
url: 'https://www.palmier.io/',
|
||||
},
|
||||
{
|
||||
company: 'Rainmaker',
|
||||
url: 'http://www.rainmaker.nyc/',
|
||||
},
|
||||
{
|
||||
company: 'Wasmer',
|
||||
url: 'https://wasmer.io/',
|
||||
},
|
||||
{
|
||||
company: 'Artie',
|
||||
url: 'https://www.artie.com/',
|
||||
},
|
||||
{
|
||||
company: 'Lumari',
|
||||
url: 'https://www.lumari.io/',
|
||||
},
|
||||
{
|
||||
company: 'Hitpay',
|
||||
url: 'https://www.hitpayapp.com/',
|
||||
},
|
||||
{
|
||||
company: 'Tempo',
|
||||
url: 'https://www.tempo.new/',
|
||||
},
|
||||
{
|
||||
company: 'SalesPatriot',
|
||||
url: 'https://www.salespatriot.com/',
|
||||
},
|
||||
{
|
||||
company: 'Surge',
|
||||
url: 'https://surge.app/',
|
||||
},
|
||||
{
|
||||
company: 'Linum',
|
||||
url: 'https://www.linum.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Rebill',
|
||||
url: 'https://www.rebill.com/',
|
||||
},
|
||||
{
|
||||
company: 'Careswift',
|
||||
url: 'https://www.careswift.com/',
|
||||
},
|
||||
{
|
||||
company: 'Autumn',
|
||||
url: 'https://useautumn.com/',
|
||||
},
|
||||
{
|
||||
company: 'Rollstack',
|
||||
url: 'https://www.rollstack.com/',
|
||||
},
|
||||
{
|
||||
company: 'OpenNote',
|
||||
url: 'https://opennote.com/',
|
||||
},
|
||||
{
|
||||
company: 'Coverage Cat',
|
||||
url: 'https://www.coveragecat.com/',
|
||||
},
|
||||
{
|
||||
company: 'Flair Labs',
|
||||
url: 'https://www.flairlabs.ai/',
|
||||
},
|
||||
{
|
||||
company: 'Percival',
|
||||
url: 'https://www.percivaltech.com/',
|
||||
},
|
||||
{
|
||||
company: 'Morphik',
|
||||
url: 'https://morphik.ai/',
|
||||
},
|
||||
{
|
||||
company: 'DrDroid',
|
||||
url: 'https://drdroid.io/',
|
||||
},
|
||||
{
|
||||
company: 'Nautilus',
|
||||
url: 'https://nautilus.co/',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default stateOfStartupsData
|
||||
@@ -89,6 +89,7 @@
|
||||
"typescript": "catalog:",
|
||||
"ui": "workspace:*",
|
||||
"ui-patterns": "workspace:*",
|
||||
"unicornstudio-react": "2.0.1-1",
|
||||
"use-debounce": "^7.0.1",
|
||||
"vanilla-tilt": "1.7.0",
|
||||
"yup": "^1.4.0"
|
||||
|
||||
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"history":[{"breakpoints":[],"visible":true,"aspectRatio":1,"userDownsample":1,"layerType":"effect","type":"beam","usesPingPong":false,"speed":0.25,"trackMouse":0.2,"mouseMomentum":1,"mouseSpring":0,"trackAxes":"xy","texture":false,"animating":false,"isMask":0,"compiledFragmentShaders":["#version 300 es\nprecision highp float;\nprecision highp int;in vec2 vTextureCoord;uniform sampler2D uTexture;uniform float uTime;uniform vec2 uMousePos;\nuniform vec2 uResolution;\nvec3 blend (int blendMode, vec3 src, vec3 dst) {\nreturn src + dst;\n}vec4 applyLayerMix(vec4 color, vec4 bg, float amount) {\ncolor.rgb = mix(bg.rgb, color.rgb, amount);\ncolor.a = max(bg.a, amount);\nreturn color;\n}uvec2 pcg2d(uvec2 v) {\nv = v * 1664525u + 1013904223u;\nv.x += v.y * v.y * 1664525u + 1013904223u;\nv.y += v.x * v.x * 1664525u + 1013904223u;\nv ^= v >> 16;\nv.x += v.y * v.y * 1664525u + 1013904223u;\nv.y += v.x * v.x * 1664525u + 1013904223u;\nreturn v;\n}float randFibo(vec2 p) {\nuvec2 v = floatBitsToUint(p);\nv = pcg2d(v);\nuint r = v.x ^ v.y;\nreturn float(r) / float(0xffffffffu);\n}float deband() {\nreturn (randFibo(gl_FragCoord.xy) - 0.5) / 255.0;\n}const float TAU = 6.28318530718;vec3 Tonemap_tanh(vec3 x) {\nx = clamp(x, -40.0, 40.0);\nreturn (exp(x) - exp(-x)) / (exp(x) + exp(-x));\n}out vec4 fragColor;const float PI = 3.14159265359;\nconst float TWO_PI = 2.0 * PI;float luma(vec3 color) {\nreturn dot(color, vec3(0.299, 0.587, 0.114));\n}mat2 rot(float a) {\nreturn mat2(cos(a),-sin(a),sin(a),cos(a));\n}float drawPoint(vec2 uv, vec2 center, float scale, vec2 resolution, float skewVal, float angleVal, float thickness) {\nuv.x *= resolution.x/resolution.y;\ncenter.x *= resolution.x/resolution.y;\nvec2 skew = vec2(skewVal, 1. - skewVal) * 2.;\nuv = uv * rot(angleVal * TWO_PI) * skew;\ncenter = center * rot(angleVal * TWO_PI) * skew;\nfloat dist = distance(uv, center);\nfloat radius = scale * 0.25;float brightness = radius / max(0.0001, dist);\nbrightness = mix(brightness, brightness*brightness, thickness);\nreturn brightness;\n}float getBeam(vec2 uv, vec2 pos, float radius, float angleVal, float skewVal, float thickness, float time, float phaseVal, vec2 resolution) {\nreturn drawPoint(uv, pos, radius, resolution, skewVal, angleVal, thickness);\n}void main() {\nvec2 uv = vTextureCoord;\nvec4 bg = texture(uTexture, uv);\nvec2 pos = vec2(0.6848196827548905, 1.2682986419267774) + mix(vec2(0), (uMousePos-0.5), 0.2000);\nfloat beam = getBeam(uv, pos, 3.2120, 0.1728, 0.5000, 0.0000, uTime, 0.0000, uResolution);\nvec3 beamColor = beam * vec3(0.6588235294117647, 0.6588235294117647, 0.6588235294117647);\nfloat ssBeam = smoothstep(0.0, 1.0, beam);\nbeamColor = mix(beamColor, vec3(0.6588235294117647, 0.6588235294117647, 0.6588235294117647), mix(1.0, ssBeam, 0.0000)) * beam;float dither = deband();\nvec3 result = Tonemap_tanh(beamColor);\nfloat lum = luma(beamColor);\nresult += dither;float layerMix = 1.0000;\nvec4 color;vec3 blended = blend(1, result, bg.rgb);\ncolor = applyLayerMix(vec4(blended, 1.0), bg, layerMix);\nfragColor = color;}"],"compiledVertexShaders":["#version 300 es\nprecision mediump float;in vec3 aVertexPosition;\nin vec2 aTextureCoord;uniform mat4 uMVMatrix;\nuniform mat4 uPMatrix;\nuniform mat4 uTextureMatrix;out vec2 vTextureCoord;\nout vec3 vVertexPosition;void main() {\ngl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);\nvTextureCoord = (uTextureMatrix * vec4(aTextureCoord, 0.0, 1.0)).xy;\n}"],"data":{"depth":false,"uniforms":{},"isBackground":false},"id":"beam"},{"breakpoints":[],"visible":true,"aspectRatio":1,"userDownsample":1,"layerType":"effect","type":"gradientMap","usesPingPong":false,"speed":0.5,"texture":false,"animating":false,"mouseMomentum":0,"mouseSpring":0,"isMask":0,"compiledFragmentShaders":["#version 300 es\nprecision highp float;in vec2 vTextureCoord;uniform sampler2D uTexture;uniform float uTime;vec3 getColor(int index) {\nswitch(index) {\ncase 0: return vec3(0, 0.4235294117647059, 0.2196078431372549);\ncase 1: return vec3(0.058823529411764705, 0.6509803921568628, 0.37254901960784315);\ncase 2: return vec3(0.5803921568627451, 0.9176470588235294, 0.6862745098039216);\ndefault: return vec3(0.0);\n}\n}float getStop(int index) {\nswitch(index) {\ncase 0: return 0.1094;\ncase 1: return 0.6344;\ncase 2: return 0.8281;\ndefault: return 0.0;\n}\n}uvec2 pcg2d(uvec2 v) {\nv = v * 1664525u + 1013904223u;\nv.x += v.y * v.y * 1664525u + 1013904223u;\nv.y += v.x * v.x * 1664525u + 1013904223u;\nv ^= v >> 16;\nv.x += v.y * v.y * 1664525u + 1013904223u;\nv.y += v.x * v.x * 1664525u + 1013904223u;\nreturn v;\n}float randFibo(vec2 p) {\nuvec2 v = floatBitsToUint(p);\nv = pcg2d(v);\nuint r = v.x ^ v.y;\nreturn float(r) / float(0xffffffffu);\n}float deband() {\nreturn (randFibo(gl_FragCoord.xy) - 0.5) / 255.0;\n}float luma(vec3 color) {\nreturn dot(color, vec3(0.299, 0.587, 0.114));\n}vec3 linear_from_srgb(vec3 rgb) {\nreturn pow(max(rgb, vec3(0.0)), vec3(2.2));\n}vec3 srgb_from_linear(vec3 lin) {\nreLine truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
@@ -15,6 +15,19 @@ module.exports = config({
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'purple-sos': {
|
||||
100: 'hsl(293 80% 97% / <alpha-value>)',
|
||||
200: 'hsl(293 85% 91% / <alpha-value>)',
|
||||
300: 'hsl(293 90% 80% / <alpha-value>)',
|
||||
400: 'hsl(293 95% 67% / <alpha-value>)',
|
||||
500: 'hsl(293 100% 54% / <alpha-value>)',
|
||||
600: 'hsl(293 100% 44% / <alpha-value>)',
|
||||
700: 'hsl(293 100% 34% / <alpha-value>)',
|
||||
800: 'hsl(293 95% 22% / <alpha-value>)',
|
||||
900: 'hsl(293 90% 12% / <alpha-value>)',
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
'flash-code': {
|
||||
'0%': { backgroundColor: 'rgba(63, 207, 142, 0.1)' },
|
||||
|
||||
Generated
+21
@@ -1881,6 +1881,9 @@ importers:
|
||||
ui-patterns:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui-patterns
|
||||
unicornstudio-react:
|
||||
specifier: 2.0.1-1
|
||||
version: 2.0.1-1(next@15.5.14(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
use-debounce:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(react@18.3.1)
|
||||
@@ -17823,6 +17826,17 @@ packages:
|
||||
resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
unicornstudio-react@2.0.1-1:
|
||||
resolution: {integrity: sha512-+k2fskodRSLFGoq2Oj1YYbmweRb0sNl0sztq1yoJhNHfIKSINFy2q1nFIfzfVjx34NDnPckw1iIhwVk02DQUtw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
peerDependencies:
|
||||
next: '*'
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
peerDependenciesMeta:
|
||||
next:
|
||||
optional: true
|
||||
|
||||
unified@10.1.2:
|
||||
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
|
||||
|
||||
@@ -37099,6 +37113,13 @@ snapshots:
|
||||
|
||||
unicorn-magic@0.4.0: {}
|
||||
|
||||
unicornstudio-react@2.0.1-1(next@15.5.14(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
next: 15.5.14(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
|
||||
unified@10.1.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.8
|
||||
|
||||
Reference in new issue
Block a user