wip: fist commit

This commit is contained in:
Danny White committed 2025-08-22 12:45:02 +10:00
1 parent 112cc89c3d
commit b364330e51
47 files changed
+3609 -433

No files matched your search

@@ -0,0 +1,39 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateAICodingToolsSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.team_count !== 'unset') {
whereClauses.push(`team_count = '${activeFilters.team_count}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
unnest(ai_coding_tools) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
ORDER BY total DESC;
`
}
export function AICodingToolsChart() {
return (
<GenericChartWithQuery
title="Which AI coding tools do you use?"
targetColumn="ai_coding_tools"
filterColumns={['person_age', 'team_count', 'funding_stage']}
generateSQLQuery={generateAICodingToolsSQL}
/>
)
}
@@ -0,0 +1,37 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateDatabasesSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.startup_age !== 'unset') {
whereClauses.push(`startup_age = '${activeFilters.startup_age}'`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
unnest(ai_models_used) AS model,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY model
ORDER BY total DESC;`
}
export function AIModelsChart() {
return (
<GenericChartWithQuery
title="Which AI models are you using or planning to use?"
targetColumn="ai_models_used"
filterColumns={['funding_stage', 'startup_age', 'currently_monetizing']}
generateSQLQuery={generateDatabasesSQL}
/>
)
}
@@ -0,0 +1,41 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateAcceleratorParticipationSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`accelerator_participation_normalized IS NOT NULL`)
if (activeFilters.previous_company !== 'unset') {
whereClauses.push(`previous_company = '${activeFilters.previous_company}'`)
}
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
const whereClause = `WHERE ${whereClauses.join('\n AND ')}`
return `SELECT
accelerator_participation_normalized,
COUNT(*) AS total
FROM responses_2025
${whereClause}
GROUP BY accelerator_participation_normalized
ORDER BY total DESC;`
}
export function AcceleratorParticipationChart() {
return (
<GenericChartWithQuery
title="If your startup has participated in an accelerator, which one?"
targetColumn="accelerator_participation_normalized"
filterColumns={['previous_company', 'headquarters', 'funding_stage']}
generateSQLQuery={generateAcceleratorParticipationSQL}
/>
)
}
@@ -0,0 +1,34 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
// Generate SQL query for team count chart
function generatePersonAgeSQL(activeFilters) {
const whereClauses = []
if (activeFilters.role !== 'unset') {
whereClauses.push(`role = '${activeFilters.role}'`)
}
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
person_age,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY person_age
ORDER BY person_age ASC`
}
export function PersonAgeChart() {
return (
<GenericChartWithQuery
title="What is your age?"
targetColumn="person_age"
filterColumns={['role', 'headquarters']}
generateSQLQuery={generatePersonAgeSQL}
/>
)
}
@@ -0,0 +1,39 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateBackendStackSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
unnest(backend_stack) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
ORDER BY total DESC;
`
}
export function BackendStackChart() {
return (
<GenericChartWithQuery
title="What is your startup's backend stack?"
targetColumn="backend_stack"
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
generateSQLQuery={generateBackendStackSQL}
/>
)
}
@@ -0,0 +1,53 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
// Generate SQL query for team count chart
function generateBiggestChallengeSQL(activeFilters) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`biggest_challenge IS NOT NULL`)
if (activeFilters.team_count !== 'unset') {
whereClauses.push(`team_count = '${activeFilters.team_count}'`)
}
if (activeFilters.ai_models_used !== 'unset') {
whereClauses.push(`'${activeFilters.ai_models_used}' = ANY(ai_models_used)`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `WITH biggest_challenge_mapping AS (
SELECT
biggest_challenge,
CASE
-- WHEN biggest_challenge = 'Customer acquisition' THEN 'Acquisition'
-- WHEN biggest_challenge = 'Technical complexity' THEN 'Technical'
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 (
<GenericChartWithQuery
title="What’s the biggest business challenge your startup is facing today?"
targetColumn="biggest_challenge"
filterColumns={['team_count', 'ai_models_used', 'currently_monetizing']}
generateSQLQuery={generateBiggestChallengeSQL}
/>
)
}
@@ -0,0 +1,41 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateCloudProvidersSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
if (activeFilters.backend_stack !== 'unset') {
whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `WITH expanded AS (
SELECT unnest(cloud_providers) AS technology
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
)
SELECT
technology,
COUNT(*) AS total
FROM expanded
GROUP BY technology
ORDER BY total DESC;`
}
export function CloudProvidersChart() {
return (
<GenericChartWithQuery
title="Which cloud provider(s) is your startup using?"
targetColumn="observability"
filterColumns={['currently_monetizing', 'money_raised', 'backend_stack']}
generateSQLQuery={generateCloudProvidersSQL}
/>
)
}
@@ -0,0 +1,48 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateDataWarehousesSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.frontend_stack !== 'unset') {
whereClauses.push(`'${activeFilters.frontend_stack}' = ANY(frontend_stack)`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
WITH expanded AS (
SELECT unnest(data_warehouses) AS technology
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
)
SELECT
technology,
COUNT(*) AS total
FROM expanded
WHERE technology NOT IN ('We don''t use a data warehouse', 'Not sure')
GROUP BY technology
ORDER BY total DESC;
`
}
export function DataWarehousesChart() {
return (
<GenericChartWithQuery
title="Which data warehouse(s) is your startup using? (Filtered)"
targetColumn="data_warehouses"
filterColumns={['headquarters', 'industry_normalized', 'person_age', 'frontend_stack']}
generateSQLQuery={generateDataWarehousesSQL}
/>
)
}
@@ -0,0 +1,37 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateDatabasesSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.team_count !== 'unset') {
whereClauses.push(`team_count = '${activeFilters.team_count}'`)
}
if (activeFilters.ai_models_used !== 'unset') {
whereClauses.push(`'${activeFilters.ai_models_used}' = ANY(ai_models_used)`)
}
if (activeFilters.backend_stack !== 'unset') {
whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
unnest(databases) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
ORDER BY total DESC;`
}
export function DatabasesChart() {
return (
<GenericChartWithQuery
title="Which database(s) is your startup using?"
targetColumn="databases"
filterColumns={['team_count', 'ai_models_used', 'backend_stack']}
generateSQLQuery={generateDatabasesSQL}
/>
)
}
@@ -0,0 +1,71 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Button,
} from 'ui'
import { ChevronsUpDown } from 'lucide-react'
// Dropdown component for SQL
export function FilterDropdown({
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} />}
>
{selectedValue === 'unset' ? (
<div className="w-full flex gap-1">
<p className="text-foreground-lighter">{filterConfig.label.toLowerCase()}</p>
</div>
) : (
<div className="w-full flex gap-1">
<p className="text-foreground-lighter">{filterConfig.label.toLowerCase()}</p>
<p className="text-foreground">{selectedValue}</p>
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{filterConfig.options
.filter((opt) => opt.value !== 'unset')
.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => setFilterValue(filterKey, option.value)}
className={selectedValue === option.value ? 'text-brand-600' : ''}
>
{option.label}
</DropdownMenuItem>
))}
{selectedValue !== 'unset' && (
<div className="border-t border-border mt-1 pt-1">
<DropdownMenuItem
onClick={() => setFilterValue(filterKey, 'unset')}
className="text-foreground-lighter"
>
Clear filter
</DropdownMenuItem>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,38 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateFrontendStackSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
unnest(frontend_stack) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
ORDER BY total DESC;
`
}
export function FrontendStackChart() {
return (
<GenericChartWithQuery
title="What frontend technologies are your startup using?"
targetColumn="frontend_stack"
filterColumns={['headquarters', 'industry_normalized', 'person_age']}
generateSQLQuery={generateFrontendStackSQL}
/>
)
}
@@ -0,0 +1,101 @@
'use client'
// import { Bar, BarChart, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
// import { ChartContainer } from 'ui'
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis, Cell } from 'recharts'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui'
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from 'ui'
export const description = 'A bar chart with a custom label'
// const chartData = [
// { month: "January", desktop: 186, mobile: 80 },
// { month: "February", desktop: 305, mobile: 200 },
// { month: "March", desktop: 237, mobile: 120 },
// { month: "April", desktop: 73, mobile: 190 },
// { month: "May", desktop: 209, mobile: 130 },
// { month: "June", desktop: 214, mobile: 140 },
// ]
const chartData = [
{ label: '1–10', value: 91.1 },
{ label: '11–50', value: 6.3 },
{ label: '51–100', value: 1.2 },
{ label: '101–250', value: 0.8 },
{ label: '250+', value: 0.6 },
]
const chartConfig = {
value: {
label: 'Value',
color: 'hsl(var(--brand-default))',
},
label: {
color: '#ff0000',
},
} satisfies ChartConfig
export function FullTimeEmployees() {
return (
<Card>
<CardHeader>
<CardTitle>How many full-time employees does your startup have?</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart
accessibilityLayer
data={chartData}
layout="vertical"
margin={{
right: 0,
}}
>
<CartesianGrid horizontal={false} />
<YAxis
dataKey="label"
type="category"
tickLine={false}
tickMargin={64}
axisLine={false}
// tickFormatter={(value) => value.slice(0, 3)}
hide={false} // Show the YAxis
width={64} // Adjust width as needed for your labels
tick={{
// fill: 'hsl(var(--foreground-lighter))', // Text color TODO not working
className: 'text-foreground-lighter', // Also not accessible
fontSize: 12,
textAnchor: 'start', // Left align
dx: 0, // Horizontal offset
}}
/>
<XAxis dataKey="value" type="number" hide />
{/* <ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
/> */}
<Bar dataKey="value" layout="vertical" radius={4}>
{/* To set per-bar colors in Recharts, `fill` should be applied to the Cell child */}
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={index === 0 ? 'hsl(var(--brand-default))' : 'hsl(var(--brand-300))'}
/>
))}
<LabelList
dataKey="value"
position="right"
offset={8}
className="fill-foreground"
fontSize={12}
formatter={(value: number) => `${value}%`}
/>
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
@@ -0,0 +1,53 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateFundingStageSQL(activeFilters) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.team_count !== 'unset') {
whereClauses.push(`team_count = '${activeFilters.team_count}'`)
}
if (activeFilters.previous_company !== 'unset') {
// Convert string to boolean for the SQL query
const previousCompanyBool = activeFilters.previous_company === 'true'
whereClauses.push(`previous_company = ${previousCompanyBool}`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT * FROM (
SELECT
CASE
WHEN funding_stage IN ('Series A', 'Series B', 'Series C', 'Series D or later') THEN 'Series A+'
ELSE funding_stage
END AS funding_stage,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY CASE
WHEN funding_stage IN ('Series A', 'Series B', 'Series C', 'Series D or later') THEN 'Series A+'
ELSE funding_stage
END
) subquery
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
ELSE 5
END;`
}
export function FundingStageChart() {
return (
<GenericChartWithQuery
title="What stage of funding is your startup in?"
targetColumn="funding_stage"
filterColumns={['headquarters', 'team_count', 'previous_company']}
generateSQLQuery={generateFundingStageSQL}
/>
)
}
@@ -0,0 +1,431 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { createClient } from '@supabase/supabase-js'
import { Card, CardContent, CardHeader, CardTitle, Button } from 'ui'
import { FilterDropdown } from './FilterDropdown'
import { ChevronDown, UnfoldVertical } from 'lucide-react'
import TwoOptionToggle from '../../../studio/components/ui/TwoOptionToggle'
import { SurveyCodeWindow } from './SurveyCodeWindow'
import CodeBlock from '~/components/CodeBlock/CodeBlock'
// Create a separate Supabase client for your external project
const externalSupabase = createClient(
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY!
)
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
}
// Custom hook to fetch filter options from Supabase
function useFilterOptions(filterColumns: string[], shouldFetch: boolean) {
const [filters, setFilters] = useState<Filters>({})
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!shouldFetch) return
async function fetchFilterOptions() {
try {
setIsLoading(true)
setError(null)
console.log('Fetching filter options from Supabase for columns:', filterColumns)
const filterOptions: Filters = {}
for (const column of filterColumns) {
const { data, error: fetchError } = await externalSupabase
.from('responses_2025')
.select(column)
.not(column, 'is', null)
if (fetchError) {
console.error(`Error fetching ${column} options:`, fetchError)
continue
}
console.log(`Raw data for ${column}:`, data)
// Extract all individual values from PostgreSQL arrays
const allValues = data.flatMap((row: any) => {
const value = row[column]
console.log(
`Processing value for ${column}:`,
value,
typeof value,
Array.isArray(value)
)
if (Array.isArray(value)) {
// Handle PostgreSQL arrays
return value.filter((item: any) => item && item.length > 0)
} else if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) {
// Handle PostgreSQL array format like {Firebase,MongoDB,MySQL}
const innerContent = value.slice(1, -1) // Remove { and }
if (innerContent.trim() === '') {
return []
}
return innerContent
.split(',')
.map((item: string) => item.trim())
.filter((item: string) => item.length > 0)
}
return [value]
})
console.log(`All values for ${column}:`, allValues)
// Get unique values and sort them
const uniqueValues = [...new Set(allValues)].sort()
console.log(`Unique values for ${column}:`, uniqueValues)
filterOptions[column] = {
label: column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
options: [
{
value: 'unset',
label: `All ${column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}s`,
},
...uniqueValues.map((value) => ({
value: String(value), // Convert to string for React
label: String(value), // Convert to string for display
})),
],
}
}
console.log('Final filter options:', filterOptions)
setFilters(filterOptions)
} catch (err: any) {
console.error('Error fetching filter options:', err)
setError(err.message)
} finally {
setIsLoading(false)
}
}
fetchFilterOptions()
}, [filterColumns, shouldFetch])
return { filters, isLoading, error }
}
// Custom hook to fetch survey data using SQL query via RPC
function useSurveyData(sqlQuery: string, shouldFetch: boolean) {
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)
console.log('Executing SQL query:', sqlQuery)
// Execute the SQL query using Supabase RPC
const { data, error: fetchError } = await externalSupabase.rpc('execute_sql', {
query: sqlQuery,
})
if (fetchError) {
console.error('Error executing SQL query:', fetchError)
setError(fetchError.message)
return
}
console.log('Raw data from SQL query:', data)
// Calculate total for percentage calculation
const total = data.reduce((sum: number, row: any) => sum + parseInt(row.total), 0)
// Transform the data to match chart format
const processedData = data.map((row: any) => {
const rawPercentage = total > 0 ? (parseInt(row.total) / 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
}
})
console.log('Processed chart data:', processedData)
setChartData(processedData)
} catch (err: any) {
console.error('Error in fetchData:', err)
setError(err.message)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [sqlQuery, shouldFetch])
return { chartData, isLoading, error }
}
interface GenericChartWithQueryProps {
title: string
targetColumn: string
filterColumns: string[]
generateSQLQuery: (activeFilters: Record<string, string>) => string
}
export function GenericChartWithQuery({
title,
targetColumn,
filterColumns,
generateSQLQuery,
}: GenericChartWithQueryProps) {
const [isInView, setIsInView] = useState(false)
const chartRef = useRef<HTMLDivElement>(null)
const [shouldAnimateBars, setShouldAnimateBars] = useState(false)
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
// Intersection observer to trigger data loading
useEffect(() => {
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 (chartRef.current) {
observer.observe(chartRef.current)
}
return () => {
if (chartRef.current) {
observer.unobserve(chartRef.current)
}
}
}, [hasLoadedOnce])
// Get dynamic filter options
const {
filters,
isLoading: filtersLoading,
error: filtersError,
} = useFilterOptions(filterColumns, isInView)
// Start with all filters unset (showing "all")
const [activeFilters, setActiveFilters] = useState(
filterColumns.reduce(
(acc: Record<string, string>, col: string) => ({ ...acc, [col]: 'unset' }),
{}
)
)
// Generate the SQL query string
const sqlQuery = generateSQLQuery(activeFilters)
// Use the custom hook to fetch data
const { chartData, isLoading: dataLoading, error: dataError } = useSurveyData(sqlQuery, isInView)
// Reset animation state when filters change
useEffect(() => {
setShouldAnimateBars(false)
}, [activeFilters])
// Trigger bar animation when data loads
useEffect(() => {
if (!dataLoading && !filtersLoading && chartData.length > 0 && !shouldAnimateBars) {
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
setShouldAnimateBars(true)
}, 100)
return () => clearTimeout(timer)
}
}, [dataLoading, filtersLoading, chartData.length, shouldAnimateBars])
const [view, setView] = useState<'chart' | 'sql'>('chart')
const setFilterValue = (filterKey: string, value: string) => {
setActiveFilters((prev: Record<string, string>) => ({
...prev,
[filterKey]: value,
}))
}
const isLoading = filtersLoading || dataLoading
const error = filtersError || dataError
// Find the maximum value for scaling
const maxValue = chartData.length > 0 ? Math.max(...chartData.map((item) => item.value)) : 0
// State for expand/collapse
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 (FIXED_HEIGHT - 40px for button area)
const skeletonData = [
{ label: 'Loading', value: 0, rawValue: 0 },
{ label: 'Loading', value: 0, rawValue: 0 },
{ label: 'Loading', value: 0, rawValue: 0 },
]
const displayData = isLoading ? skeletonData : chartData || []
return (
<div ref={chartRef} className="w-full bg-surface-100 border-y">
<header className="px-6 py-5">
<h3 className="text-foreground-light text-sm">{title}</h3>
</header>
<div
className={`${view === 'chart' ? 'bg-surface-100' : 'bg-surface-75'} border-b last:border-none`}
>
<div
className="overflow-hidden relative"
style={{
height: isExpanded ? 'auto' : `${FIXED_HEIGHT}px`,
}}
>
{error ? (
<div className="flex items-center justify-center h-full">
<p className="text-danger">Error: {error}</p>
</div>
) : view === 'chart' ? (
<div className="flex flex-col h-full w-full justify-between px-6 pt-4 pb-10">
{/* Each bar as a vertical stack: label above, bar below */}
<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
? 'text-brand-link dark:text-brand'
: 'text-foreground'
: 'text-foreground-muted'
}`}
>
<span>{item.label}</span>
<span>{item.value < 1 ? '<1%' : `${item.value}%`}</span>
</div>
{/* Entire bar (including background) */}
<div
className="h-[14px] flex items-center"
style={
{
background: `repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
color-mix(in srgb, hsl(var(--foreground-muted)) 30%, transparent) 4px,
color-mix(in srgb, hsl(var(--foreground-muted)) 30%, transparent) 6px
)`,
'--reference': maxValue,
'--bar-value': item.rawValue || item.value,
'--index': index,
} as React.CSSProperties
}
>
{/* Filled portion of the bar */}
<div
className={`h-full ${item.value === maxValue ? 'bg-brand' : 'bg-foreground-muted'}`}
style={{
width: `calc(max(0.5%, (var(--bar-value) / var(--reference)) * 100%))`,
transform: shouldAnimateBars ? 'scaleX(1)' : 'scaleX(0)',
transformOrigin: 'left',
transition: `transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.1}s`,
}}
/>
</div>
</div>
))}
</div>
</div>
) : (
<CodeBlock lang="sql" showLineNumbers={true} className="rounded-none border-none">
{sqlQuery}
</CodeBlock>
)}
{/* Expand button overlay */}
{!isExpanded && !isLoading && !error && 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"
// iconRight={<UnfoldVertical />}
onClick={() => setIsExpanded(true)}
className="shadow-sm"
>
{/* Show {chartData.length - 3} more */}
Show more
</Button>
</div>
)}
</div>
</div>
{/* Filters and toggle */}
<div className="flex flex-row flex-wrap gap-4 p-4 justify-between">
{filters && activeFilters && setFilterValue && (
<div className="flex flex-wrap gap-4">
{Object.entries(filters).map(([filterKey, filterConfig]) => (
<FilterDropdown
key={filterKey}
filterKey={filterKey}
filterConfig={filterConfig}
selectedValue={activeFilters[filterKey]}
setFilterValue={setFilterValue}
/>
))}
</div>
)}
<TwoOptionToggle
options={['SQL', 'chart']}
activeOption={view}
onClickOption={setView}
borderOverride="border-overlay"
/>
</div>
</div>
)
}
@@ -0,0 +1,39 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateHeadquartersSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.startup_age !== 'unset') {
whereClauses.push(`startup_age = '${activeFilters.startup_age}'`)
}
if (activeFilters.previous_company !== 'unset') {
// Convert string to boolean for the SQL query
const previousCompanyBool = activeFilters.previous_company === 'true'
whereClauses.push(`previous_company = ${previousCompanyBool}`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
headquarters,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY headquarters
ORDER BY total DESC;`
}
export function HeadquartersChart() {
return (
<GenericChartWithQuery
title="Where is your startup headquartered?"
targetColumn="headquarters"
filterColumns={['funding_stage', 'startup_age', 'previous_company']}
generateSQLQuery={generateHeadquartersSQL}
/>
)
}
@@ -0,0 +1,52 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
// Generate SQL query for team count chart
function generateIndustrySQL(activeFilters) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`industry_normalized IS NOT NULL`)
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.market_model !== 'unset') {
whereClauses.push(`'${activeFilters.market_model}' = ANY(market_model)`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `WITH industry_mapping AS (
SELECT
industry_normalized,
CASE
WHEN industry_normalized = 'Developer tools and platforms' THEN 'Dev tools'
WHEN industry_normalized = 'AI / ML tools' THEN 'AI / ML'
WHEN industry_normalized IN ('SaaS', 'Dev tools', 'AI / ML', 'Consumer', 'Education', 'eCommerce', 'Fintech', 'Healthtech') THEN industry_normalized
ELSE 'Other'
END AS industry_clean
FROM responses_2025
${whereClause}
)
SELECT
industry_clean AS industry_normalized,
COUNT(*) AS total
FROM industry_mapping
GROUP BY industry_clean
ORDER BY total DESC;`
}
export function IndustryChart() {
return (
<GenericChartWithQuery
title="What is your startup's primary industry or target customer segment?"
targetColumn="industry_normalized"
filterColumns={['person_age', 'funding_stage', 'market_model']}
generateSQLQuery={generateIndustrySQL}
/>
)
}
@@ -0,0 +1,44 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateInitialPayingCustomersSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
// whereClauses.push(`initial_paying_customers IS NOT NULL`)
// whereClauses.push(`initial_paying_customers != ''`)
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.market_model !== 'unset') {
whereClauses.push(`'${activeFilters.market_model}' = ANY(market_model)`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
source,
COUNT(DISTINCT id) AS respondents
FROM (
SELECT id, unnest(initial_paying_customers) AS source
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
) sub
GROUP BY source
ORDER BY respondents DESC;`
}
export function InitialPayingCustomersChart() {
return (
<GenericChartWithQuery
title="Where did your startup’s initial paying customers come from?"
targetColumn="initial_paying_customers"
filterColumns={['industry_normalized', 'market_model', 'currently_monetizing']}
generateSQLQuery={generateInitialPayingCustomersSQL}
/>
)
}
@@ -0,0 +1,34 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateMonetizationSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
currently_monetizing,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY currently_monetizing
ORDER BY total DESC;`
}
export function MonetizationChart() {
return (
<GenericChartWithQuery
title="Is your startup monetizing today?"
targetColumn="currently_monetizing"
filterColumns={['headquarters', 'money_raised']}
generateSQLQuery={generateMonetizationSQL}
chartType="pie"
/>
)
}
@@ -0,0 +1,43 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateMustHaveDevToolsSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`must_have_dev_tools IS NOT NULL`)
whereClauses.push(`must_have_dev_tools != ''`)
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
if (activeFilters.backend_stack !== 'unset') {
whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
must_have_dev_tools,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY must_have_dev_tools
ORDER BY total DESC
LIMIT 100;`
}
export function MustHaveDevToolsChart() {
return (
<GenericChartWithQuery
title="What are your must-have dev tools?"
targetColumn="must_have_dev_tools"
filterColumns={['currently_monetizing', 'money_raised', 'backend_stack']}
generateSQLQuery={generateMustHaveDevToolsSQL}
/>
)
}
@@ -0,0 +1,44 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateNewIdeasSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`must_have_dev_tools IS NOT NULL`)
whereClauses.push(`must_have_dev_tools != ''`)
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.role !== 'unset') {
whereClauses.push(`role = '${activeFilters.role}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
avenue,
COUNT(DISTINCT id) AS respondents
FROM (
SELECT id, unnest(new_ideas) AS avenue
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
) sub
GROUP BY avenue
ORDER BY respondents DESC;`
}
export function NewIdeasChart() {
return (
<GenericChartWithQuery
title="Where do you usually discover new dev tools or startup ideas?"
targetColumn="new_ideas"
filterColumns={['person_age', 'role', 'industry_normalized']}
generateSQLQuery={generateNewIdeasSQL}
/>
)
}
@@ -0,0 +1,45 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateNewslettersPaidForSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
unnest(newsletters_paid_for) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
UNION ALL
SELECT
'None of the above' AS technology,
COUNT(*) AS total
FROM responses_2025
WHERE newsletters_paid_for = '{}'
ORDER BY total DESC;
`
}
export function NewslettersPaidForChart() {
return (
<GenericChartWithQuery
title="Which newsletters do you currently pay for subscriptions?"
targetColumn="newsletters_paid_for"
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
generateSQLQuery={generateNewslettersPaidForSQL}
/>
)
}
@@ -0,0 +1,38 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateObservabilitySQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `WITH expanded AS (
SELECT unnest(observability) AS technology
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
)
SELECT
technology,
COUNT(*) AS total
FROM expanded
WHERE technology NOT IN ('We don''t use observability tools yet', 'Not yet')
GROUP BY technology
ORDER BY total DESC;`
}
export function ObservabilityChart() {
return (
<GenericChartWithQuery
title="Which tools are your startup currently using for monitoring and observability? (Filtered)"
targetColumn="observability"
filterColumns={['currently_monetizing', 'money_raised']}
generateSQLQuery={generateObservabilitySQL}
/>
)
}
@@ -0,0 +1,39 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateOpenTelemetrySQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
use_opentelemetry,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY use_opentelemetry
ORDER BY
CASE use_opentelemetry
WHEN 'Yes' THEN 1
WHEN 'Not yet, but planning to' THEN 2
WHEN 'No' THEN 3
END;`
}
export function OpenTelemetryChart() {
return (
<GenericChartWithQuery
title="Do you use OpenTelemetry in your observability stack?"
targetColumn="use_opentelemetry"
filterColumns={['currently_monetizing', 'money_raised']}
generateSQLQuery={generateOpenTelemetrySQL}
chartType="pie"
/>
)
}
@@ -0,0 +1,50 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generatePivotFreqSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// if (activeFilters.headquarters !== 'unset') {
// whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
// }
// if (activeFilters.currently_monetizing !== 'unset') {
// whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
// }
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.previous_company !== 'unset') {
// Convert string to boolean for the SQL query
const previousCompanyBool = activeFilters.previous_company === 'true'
whereClauses.push(`previous_company = ${previousCompanyBool}`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
pivots_before_current,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY pivots_before_current
ORDER BY
CASE pivots_before_current
WHEN 'Never' THEN 1
WHEN 'Once' THEN 2
WHEN 'Twice' THEN 3
WHEN 'More than twice' THEN 4
END;`
}
export function PivotFreqChart() {
return (
<GenericChartWithQuery
title="How many times did your startup have to pivot before arriving at the current idea?"
targetColumn="pivots_before_current"
// filterColumns={['headquarters', 'currently_monetizing', 'person_age', 'previous_company']}
filterColumns={['person_age', 'previous_company']}
generateSQLQuery={generatePivotFreqSQL}
/>
)
}
@@ -0,0 +1,46 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generatePodcastsListenedToSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT technology, COUNT(*) AS total
FROM (
SELECT
CASE
WHEN podcasts_listened_to = '{}' THEN 'None'
WHEN podcast = 'I don''t listen to podcasts regularly' THEN 'None'
ELSE podcast
END AS technology
FROM responses_2025
CROSS JOIN LATERAL unnest(
CASE WHEN podcasts_listened_to = '{}' THEN ARRAY['None'] ELSE podcasts_listened_to END
) AS podcast${whereClause ? '\n' + whereClause : ''}
) t
GROUP BY technology
ORDER BY total DESC;`
}
export function PodcastsListenedToChart() {
return (
<GenericChartWithQuery
title="Which of these podcasts do you listen to regularly?"
targetColumn="podcasts_listened_to"
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
generateSQLQuery={generatePodcastsListenedToSQL}
/>
)
}
@@ -0,0 +1,42 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generatePreviousCompanySQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`previous_company IS NOT NULL`)
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
const whereClause = `WHERE ${whereClauses.join('\n AND ')}`
return `SELECT
CASE
WHEN previous_company = true THEN 'Yes'
WHEN previous_company = false THEN 'No'
-- ELSE 'Unknown'
END AS previous_company,
COUNT(*) AS total
FROM responses_2025
${whereClause}
GROUP BY previous_company
ORDER BY total DESC;`
}
export function PreviousCompanyChart() {
return (
<GenericChartWithQuery
title="Had you started a company before your current startup?"
targetColumn="previous_company"
filterColumns={['person_age', 'money_raised']}
generateSQLQuery={generatePreviousCompanySQL}
chartType="pie"
/>
)
}
@@ -0,0 +1,31 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generatePricingSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.team_count !== 'unset') {
whereClauses.push(`team_count = '${activeFilters.team_count}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
unnest(pricing) AS pricing_model,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY pricing_model
ORDER BY total DESC;
`
}
export function PricingChart() {
return (
<GenericChartWithQuery
title="TK: Pricing question"
targetColumn="pricing"
filterColumns={['team_count']}
generateSQLQuery={generatePricingSQL}
/>
)
}
@@ -0,0 +1,43 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateProblemBeingSolvedSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`problem_solving IS NOT NULL`)
whereClauses.push(`problem_solving != ''`)
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
if (activeFilters.backend_stack !== 'unset') {
whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
problem_solving,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY problem_solving
ORDER BY total DESC
LIMIT 100;`
}
export function ProblemBeingSolvedChart() {
return (
<GenericChartWithQuery
title="What problem is your startup solving?"
targetColumn="problem_solving"
filterColumns={['currently_monetizing', 'money_raised', 'backend_stack']}
generateSQLQuery={generateProblemBeingSolvedSQL}
/>
)
}
@@ -0,0 +1,349 @@
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'
import {
Bar,
BarChart,
PieChart,
Pie,
CartesianGrid,
LabelList,
XAxis,
YAxis,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from 'ui'
import CodeBlock from '~/components/CodeBlock/CodeBlock'
import type { LANG } from '~/components/CodeBlock/CodeBlock'
import { ChartConfig, ChartContainer } from 'ui'
import { SurveyCodeWindow } from './SurveyCodeWindow'
import { FilterDropdown } from './FilterDropdown'
const chartConfig = {
value: {
label: 'Value',
color: 'hsl(var(--brand-default))',
},
label: {
color: '#ff0000',
},
} satisfies ChartConfig
// Create a separate Supabase client for your external project
const externalSupabase = createClient(
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL,
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY
)
// Custom hook to fetch filter options from Supabase
function useFilterOptions(filterColumns: string[]) {
const [filters, setFilters] = useState({})
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchFilterOptions() {
try {
setIsLoading(true)
setError(null)
console.log('Fetching filter options from Supabase for columns:', filterColumns)
const filterOptions = {}
for (const column of filterColumns) {
const { data, error: fetchError } = await externalSupabase
.from('responses_2025')
.select(column)
.not(column, 'is', null)
// Remove this line for boolean columns - it excludes false values
// .not(column, 'eq', '')
if (fetchError) {
console.error(`Error fetching ${column} options:`, fetchError)
continue
}
console.log(`Raw data for ${column}:`, data)
// Extract all individual values from PostgreSQL arrays
const allValues = data.flatMap((row) => {
const value = row[column]
console.log(
`Processing value for ${column}:`,
value,
typeof value,
Array.isArray(value)
)
if (Array.isArray(value)) {
// Handle PostgreSQL arrays
return value.filter((item) => item && item.length > 0)
} else if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) {
// Handle PostgreSQL array format like {Firebase,MongoDB,MySQL}
const innerContent = value.slice(1, -1) // Remove { and }
if (innerContent.trim() === '') {
return []
}
return innerContent
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
return [value]
})
console.log(`All values for ${column}:`, allValues)
// Get unique values and sort them
const uniqueValues = [...new Set(allValues)].sort()
console.log(`Unique values for ${column}:`, uniqueValues)
filterOptions[column] = {
label: column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
options: [
{
value: 'unset',
label: `All ${column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}s`,
},
...uniqueValues.map((value) => ({
value: String(value), // Convert to string for React
label: String(value), // Convert to string for display
})),
],
}
}
console.log('Final filter options:', filterOptions)
setFilters(filterOptions)
} catch (err) {
console.error('Error fetching filter options:', err)
setError(err.message)
} finally {
setIsLoading(false)
}
}
fetchFilterOptions()
}, [filterColumns])
return { filters, isLoading, error }
}
// Custom hook to fetch survey data using SQL query via RPC
// TODO: switch out to use supabase-js instead
function useSurveyData(sqlQuery: string) {
const [chartData, setChartData] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchData() {
try {
setIsLoading(true)
setError(null)
console.log('Executing SQL query:', sqlQuery)
// Execute the SQL query using Supabase RPC
const { data, error: fetchError } = await externalSupabase.rpc('execute_sql', {
query: sqlQuery,
})
if (fetchError) {
console.error('Error executing SQL query:', fetchError)
setError(fetchError.message)
return
}
console.log('Raw data from SQL query:', data)
// Calculate total for percentage calculation
const total = data.reduce((sum, row) => sum + parseInt(row.total), 0)
// Transform the data to match chart format
const processedData = data.map((row) => ({
label: row.label || row.value || row[Object.keys(row)[0]], // Get the first column as label
value: total > 0 ? parseFloat(((parseInt(row.total) / total) * 100).toFixed(1)) : 0,
}))
console.log('Processed chart data:', processedData)
setChartData(processedData)
} catch (err) {
console.error('Error in fetchData:', err)
setError(err.message)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [sqlQuery])
return { chartData, isLoading, error }
}
export function RechartsChartWithQuery({
title,
targetColumn,
filterColumns,
generateSQLQuery,
chartType = 'bar', // TODO: Could be 'bar', 'pie', etc.
}) {
// Get dynamic filter options
const {
filters,
isLoading: filtersLoading,
error: filtersError,
} = useFilterOptions(filterColumns)
// Start with all filters unset (showing "all")
const [activeFilters, setActiveFilters] = useState(
filterColumns.reduce((acc, col) => ({ ...acc, [col]: 'unset' }), {})
)
// Generate the SQL query string
const sqlQuery = generateSQLQuery(activeFilters)
// Use the custom hook to fetch data
const { chartData, isLoading: dataLoading, error: dataError } = useSurveyData(sqlQuery)
const setFilterValue = (filterKey, value) => {
setActiveFilters((prev) => ({
...prev,
[filterKey]: value,
}))
}
const isLoading = filtersLoading || dataLoading
const error = filtersError || dataError
// Two different color arrays
// One for gradation (e.g. "Yes", "Maybe", "No")
const COLORS = [
'hsl(var(--brand-default))',
'hsl(var(--brand-500))',
'hsl(var(--foreground-light))',
]
// One for a clear binary answer (e.g. "Yes", "No")
const COLORS_BINARY = ['hsl(var(--brand-default))', 'hsl(var(--foreground-muted))']
return (
<Card className="w-full overflow-hidden">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-foreground-lighter">Loading data...</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Error: {error}</div>
</div>
) : (
<ChartContainer config={chartConfig}>
{chartType === 'bar' && (
<BarChart
accessibilityLayer
data={chartData}
layout="vertical"
margin={{
right: 0,
}}
>
<CartesianGrid horizontal={false} />
<YAxis
dataKey="label"
type="category"
tickLine={false}
tickMargin={64}
axisLine={false}
hide={false}
width={64}
tick={{
className: 'text-foreground-lighter',
fontSize: 12,
textAnchor: 'start',
dx: 0,
}}
/>
<XAxis dataKey="value" type="number" hide />
<Bar dataKey="value" layout="vertical" radius={4}>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={index === 0 ? COLORS_BINARY[0] : COLORS_BINARY[1]}
/>
))}
<LabelList
dataKey="value"
position="right"
offset={8}
className="fill-foreground"
fontSize={12}
formatter={(value: number) => `${value}%`}
/>
</Bar>
</BarChart>
)}
{chartType === 'pie' && (
<PieChart>
<Pie
data={chartData}
dataKey="value"
innerRadius={60}
outerRadius={100}
// startAngle={15}
paddingAngle={2}
// label={renderCustomizedLabel}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
chartData.length === 2
? COLORS_BINARY[index % COLORS_BINARY.length]
: COLORS[index % COLORS.length]
}
/>
))}
</Pie>
</PieChart>
)}
</ChartContainer>
)}
</CardContent>
{/* <SurveyCodeWindow
code={sqlQuery}
lang="sql"
className="w-full"
filters={filters}
activeFilters={activeFilters}
setFilterValue={setFilterValue}
/> */}
<div className="flex flex-col">
{filters && activeFilters && setFilterValue && (
<div className="flex flex-wrap gap-4 p-4 border-b border-border">
{Object.entries(filters).map(([filterKey, filterConfig]) => (
<FilterDropdown
filterKey={filterKey}
filterConfig={filterConfig}
selectedValue={activeFilters[filterKey]}
setFilterValue={setFilterValue}
/>
))}
</div>
)}
{/* <CodeBlock lang="sql" size="small" showLineNumbers={true} rounded={false}>
{sqlQuery}
</CodeBlock> */}
</div>
</Card>
)
}
@@ -0,0 +1,54 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateRegularSocialMediaUseSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.role !== 'unset') {
whereClauses.push(`role = '${activeFilters.role}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `WITH founders AS (
SELECT id, regular_social_media_use
FROM responses_2025
WHERE role = 'Founder / Co-founder'
)
SELECT
platform AS label,
COUNT(*) AS total
FROM (
SELECT id, unnest(regular_social_media_use) AS platform
FROM founders
) t
GROUP BY platform
ORDER BY total DESC;
`
}
// SELECT
// unnest(regular_social_media_use) AS social_media,
// COUNT(*) AS total
// FROM responses_2025${whereClause ? '\n' + whereClause : ''}
// GROUP BY social_media
// ORDER BY total DESC;
export function RegularSocialMediaUseChart() {
return (
<GenericChartWithQuery
title="Which social media platforms do you use at least 3× per week?"
targetColumn="regular_social_media_use"
filterColumns={['role', 'industry_normalized', 'person_age']}
generateSQLQuery={generateRegularSocialMediaUseSQL}
// isPercentageData={false}
/>
)
}
@@ -0,0 +1,52 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
// Generate SQL query for team count chart
function generateRoleSQL(activeFilters) {
const whereClauses = []
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
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;
`
}
// SELECT
// person_role_normalized,
// COUNT(*) AS total
// FROM responses_2025${whereClause ? '\n' + whereClause : ''}
// GROUP BY person_role_normalized
// ORDER BY total DESC
export function RoleChart() {
return (
<GenericChartWithQuery
title="What is your functional role at your startup?"
targetColumn="person_role_normalized"
filterColumns={['person_age', 'funding_stage', 'headquarters']}
generateSQLQuery={generateRoleSQL}
/>
)
}
@@ -0,0 +1,39 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateSalesToolsSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.team_count !== 'unset') {
whereClauses.push(`team_count = '${activeFilters.team_count}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.first_sales_hire_stage !== 'unset') {
whereClauses.push(`first_sales_hire_stage = '${activeFilters.first_sales_hire_stage}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
unnest(sales_tools) AS tool_name,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY tool_name
ORDER BY total DESC;
`
}
export function SalesToolsChart() {
return (
<GenericChartWithQuery
title="What tools are you using to manage your sales process?"
targetColumn="sales_tools"
filterColumns={['team_count', 'funding_stage', 'first_sales_hire_stage']}
generateSQLQuery={generateSalesToolsSQL}
/>
)
}
@@ -0,0 +1,45 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateSubscriptionsSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.industry_normalized !== 'unset') {
whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`)
}
if (activeFilters.currently_monetizing !== 'unset') {
whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `
SELECT
unnest(subscriptions) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
UNION ALL
SELECT
'None of the above' AS technology,
COUNT(*) AS total
FROM responses_2025
WHERE subscriptions = '{}'
ORDER BY total DESC;
`
}
export function SubscriptionsChart() {
return (
<GenericChartWithQuery
title="Which of the following subscriptions does your startup pay for your team?"
targetColumn="subscriptions"
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
generateSQLQuery={generateSubscriptionsSQL}
/>
)
}
@@ -0,0 +1,48 @@
import { SurveyPullQuote } from './SurveyPullQuote'
interface SurveyChapterProps {
number: number
title: string
description: string
pullQuote?: {
quote: string
author: string
authorPosition: string
authorAvatar: string
}
children: React.ReactNode
totalChapters?: number
}
export function SurveyChapter({
number,
title,
description,
pullQuote,
children,
totalChapters,
}: SurveyChapterProps) {
return (
<section
id={`chapter-${number}`}
className="flex flex-col bg-200 border-t last-of-type:border-b pt-40"
>
<div className="flex flex-col container max-w-[60rem] mx-auto px-6 lg:px-0">
<header className="mx-auto flex flex-col gap-4 text-center text-balance">
<h2 className="text-3xl md:text-4xl xl:text-6xl text-balance leading-tight">{title}</h2>
<p className="text-lg xl:text-xl text-foreground-light text-balance">{description}</p>
</header>
<div className="flex flex-col pb-32">{children}</div>
</div>
{pullQuote && (
<SurveyPullQuote
quote={pullQuote.quote}
author={pullQuote.author}
authorPosition={pullQuote.authorPosition}
authorAvatar={pullQuote.authorAvatar}
/>
)}
</section>
)
}
@@ -0,0 +1,143 @@
// import SectionContainer from '~/components/Layouts/SectionContainer'
import { SurveyStatCard } from './SurveyStatCard'
import { SurveyPullQuote } from './SurveyPullQuote'
import { SurveyWordCloud } from './SurveyWordCloud'
import { SurveySummarizedAnswer } from './SurveySummarizedAnswer'
import { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair'
// Charts (TODO: delete unused)
import { AcceleratorParticipationChart } from './AcceleratorParticipationChart'
import { RoleChart } from './RoleChart'
import { IndustryChart } from './IndustryChart'
import { FundingStageChart } from './FundingStageChart'
import { DatabasesChart } from './DatabasesChart'
import { AIModelsChart } from './AIModelsChart'
// import { TeamCountChart } from '~/components/SurveyResults/TeamCountChart'
// import { TechnicalFoundersChart } from '~/components/SurveyResults/TechnicalFoundersChart'
// import { PreviousCompanyChart } from '~/components/SurveyResults/PreviousCompanyChart'
// import { FundingStageChart } from '~/components/SurveyResults/FundingStageChart'
import { HeadquartersChart } from '~/components/SurveyResults/HeadquartersChart'
// import { SurveyPullQuote } from '~/components/SurveyResults/SurveyPullQuote'
// import { SurveyStatCard } from '~/components/SurveyResults/SurveyStatCard'
// import { RoleChart } from '~/components/SurveyResults/RoleChart'
// import { ProblemBeingSolvedChart } from '~/components/SurveyResults/ProblemBeingSolvedChart'
// import { PersonAgeChart } from '~/components/SurveyResults/AgeChart'
// import { MonetizationChart } from '~/components/SurveyResults/MonetizationChart'
// import { AcceleratorParticipationChart } from '~/components/SurveyResults/AcceleratorParticipationChart'
// import { PivotFreqChart } from '~/components/SurveyResults/PivotFreqChart'
// import { FrontendStackChart } from '~/components/SurveyResults/FrontendStackChart'
// import { BackendStackChart } from '~/components/SurveyResults/BackendStackChart'
import { SalesToolsChart } from '~/components/SurveyResults/SalesToolsChart'
// import { PricingChart } from '~/components/SurveyResults/PricingChart'
// import { IndustryChart } from '~/components/SurveyResults/IndustryChart'
// import { DataWarehousesChart } from '~/components/SurveyResults/DataWarehousesChart'
// import { VectorDatabasesChart } from '~/components/SurveyResults/VectorDatabasesChart'
// import { OpenTelemetryChart } from '~/components/SurveyResults/OpenTelemetryChart'
// import { ObservabilityChart } from '~/components/SurveyResults/ObservabilityChart'
// import { CloudProvidersChart } from '~/components/SurveyResults/CloudProvidersChart'
// import { MustHaveDevToolsChart } from '~/components/SurveyResults/MustHaveDevToolsChart'
import { AICodingToolsChart } from './AICodingToolsChart'
// import { SubscriptionsChart } from '~/components/SurveyResults/SubscriptionsChart'
// import { NewslettersPaidForChart } from '~/components/SurveyResults/NewslettersPaidForChart'
// import { PodcastsListenedToChart } from '~/components/SurveyResults/PodcastsListenedToChart'
import { RegularSocialMediaUseChart } from '~/components/SurveyResults/RegularSocialMediaUseChart'
import { NewIdeasChart } from '~/components/SurveyResults/NewIdeasChart'
import { InitialPayingCustomersChart } from '~/components/SurveyResults/InitialPayingCustomersChart'
import { WorldOutlookChart } from '~/components/SurveyResults/WorldOutlookChart'
import { BiggestChallengeChart } from '~/components/SurveyResults/BiggestChallengeChart'
interface SurveyChapterSectionProps {
number: string
title: string
description: string
stats?: Array<{ number: number; unit: string; label: string }>
charts?: string[]
pullQuote?: {
quote: string
author: string
authorPosition: string
authorAvatar: 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({
number,
title,
description,
stats,
charts,
pullQuote,
wordCloud,
summarizedAnswer,
rankedAnswersPair,
children,
}: SurveyChapterSectionProps) {
const chartComponents = {
RoleChart,
IndustryChart,
FundingStageChart,
AcceleratorParticipationChart,
DatabasesChart,
AICodingToolsChart,
AIModelsChart,
RegularSocialMediaUseChart,
NewIdeasChart,
InitialPayingCustomersChart,
SalesToolsChart,
WorldOutlookChart,
BiggestChallengeChart,
HeadquartersChart,
}
return (
<div
id={title.toLowerCase().replace(/\s+/g, '-')}
className="flex flex-col gap-12 border-t border-muted pt-24 mt-24"
>
<header className="flex flex-col gap-3 text-center text-balance max-w-[48rem] mx-auto pb-12">
<h3 className="text-brand-link dark:text-brand font-mono uppercase tracking-widest text-sm">
{title}
</h3>
<p className="text-foreground text-xl leading-relaxed">{description}</p>
</header>
{stats && (
<aside className="flex flex-row flex-wrap divide-x divide-muted divide-opacity-50">
{stats.map((stat, index) => (
<SurveyStatCard
key={index}
progressValue={stat.number}
unit={stat.unit}
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>
)
}
@@ -0,0 +1,33 @@
import React from 'react'
import { cn } from 'ui'
import CodeBlock from '~/components/CodeBlock/CodeBlock'
import type { LANG } from '~/components/CodeBlock/CodeBlock'
import { FilterDropdown } from './FilterDropdown'
interface Props {
code: any
lang?: LANG
className?: string
style?: React.CSSProperties
showLineNumbers?: boolean
filters?: Record<string, any>
activeFilters?: Record<string, string>
setFilterValue?: (filterKey: string, value: string) => void
}
export const SurveyCodeWindow = ({
code,
lang,
style,
className,
showLineNumbers,
filters,
activeFilters,
setFilterValue,
}: Props) => {
return (
<CodeBlock lang={lang ?? 'js'} showLineNumbers={showLineNumbers}>
{code}
</CodeBlock>
)
}
@@ -0,0 +1,32 @@
import Image from 'next/image'
export function SurveyPullQuote({
quote,
author,
authorPosition,
authorAvatar,
}: {
quote: string
author: string
authorPosition: string
authorAvatar: string
}) {
return (
<aside className="flex flex-col gap-4 text-center items-center px-6 py-24 bg-alternative border-t border-muted">
{/* TODO add quote marks via CSS instead, hang quote marks outside*/}
<p className="text-foreground-lighter text-2xl 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>
</aside>
)
}
@@ -0,0 +1,23 @@
export function SurveyRankedAnswersPair({
rankedAnswersPair,
}: {
rankedAnswersPair: Array<{ label: string; answers: string[] }>
}) {
return (
<aside className="flex flex-row flex-wrap divide-x divide-muted divide-opacity-50">
{/* <div className="flex flex-row bg-surface-100 border border-default rounded-md divide-x"> */}
{rankedAnswersPair.map((item, index) => (
<div key={index} className="flex flex-col gap-3 flex-1 px-6 py-8">
<ol className="flex flex-col gap-3">
{item.answers.map((answer, answerIndex) => (
<li key={answerIndex} className="font-mono text-brand">
#{answerIndex + 1}. {answer}
</li>
))}
</ol>
<p className="text-foreground-light text-sm">{item.label}</p>
</div>
))}
</aside>
)
}
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from 'react'
export function SurveyStatCard({
unit = '%',
label,
progressValue,
maxValue = 100,
}: {
unit?: string
label: string
progressValue?: number
maxValue?: number
}) {
const [displayValue, setDisplayValue] = useState(0)
const [hasAnimated, setHasAnimated] = useState(false)
const [shouldAnimateBar, setShouldAnimateBar] = useState(false)
const cardRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!progressValue || 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 = 600
const startValue = 0
const endValue = progressValue
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 }
)
if (cardRef.current) {
observer.observe(cardRef.current)
}
return () => {
if (cardRef.current) {
observer.unobserve(cardRef.current)
}
}
}, [progressValue, hasAnimated])
// Format display value to always show two digits
const formattedValue = displayValue.toString().padStart(2, '0')
return (
<div ref={cardRef} className="flex-1 px-6 py-8 flex flex-col gap-4">
{/* Progress bar */}
{progressValue !== undefined && (
<div
className="h-[6px] flex items-center md:mr-12"
style={
{
background: `repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
color-mix(in srgb, hsl(var(--foreground-muted)) 50%, transparent) 3px,
color-mix(in srgb, hsl(var(--foreground-muted)) 50%, transparent) 4px
)`,
} as React.CSSProperties
}
>
<div
className="h-full bg-brand origin-left"
style={{
width: `calc(max(0.5%, (${progressValue} / ${maxValue}) * 100%))`,
transform: shouldAnimateBar ? 'scaleX(1)' : 'scaleX(0)',
transition: 'transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
}}
/>
</div>
)}
<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 ? 'text-foreground' : 'text-foreground-muted'} transition-colors duration-1000`}
>
{formattedValue}
<span className="md:text-4xl">{unit}</span>
</p>
<p className="text-foreground-light text-sm text-balance md:mr-6">{label}</p>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react'
export function SurveySummarizedAnswer({ label, answers }: { label: string; answers: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
if (answers.length <= 1) return
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % answers.length)
}, 3000)
return () => clearInterval(interval)
}, [answers.length])
if (answers.length <= 1) {
return (
<div className="flex flex-col gap-3 px-6 py-8">
<p className="font-mono text-brand text-2xl">{answers[0]}</p>
<p className="text-foreground-light text-sm">{label}</p>
</div>
)
}
return (
<div className="flex flex-col gap-3 px-6 py-8">
<div className="min-h-[1.5rem] flex items-center">
<p className="font-mono text-brand text-2xl transition-opacity duration-500">
{answers[currentIndex]}
</p>
</div>
<p className="text-foreground-light text-sm">{label}</p>
</div>
)
}
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react'
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[]>([])
// Calculate the range within the current context
const counts = answers.map((answer) => answer.count)
const minCount = Math.min(...counts)
const maxCount = Math.max(...counts)
const range = maxCount - minCount
// 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)
}, 3000)
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() / 3000)) % 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, 3000)
return () => clearInterval(interval)
}, [isRotating, answers, scramblingTexts, currentItems])
return (
<aside className="flex flex-col gap-4 px-3 md:px-6">
<ol className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4">
{currentItems.map(({ text, count }, index) => (
<li
key={`${text}-${index}`}
className={`py-4 border-t border-muted border-opacity-50 [&:nth-child(-n+2)]:border-t-0 sm:[&:nth-child(-n+3)]:border-t-0 md:[&:nth-child(-n+4)]:border-t-0`}
>
<span className="font-mono md:text-lg text-center text-foreground">
{scramblingTexts[index].toUpperCase() || text.toUpperCase()}
</span>
</li>
))}
</ol>
<p className="text-foreground-lighter text-sm">{label}</p>
</aside>
)
}
@@ -0,0 +1,45 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
// Generate SQL query for team count chart
function generateTeamCountSQL(activeFilters) {
const whereClauses = []
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
team_count,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY team_count
ORDER BY
CASE team_count
WHEN '1-10' THEN 1
WHEN '11-50' THEN 2
WHEN '51-100' THEN 3
WHEN '101-250' THEN 4
WHEN '250' THEN 5
END;`
}
export function TeamCountChart() {
return (
<GenericChartWithQuery
title="How many full-time employees does your startup have?"
targetColumn="team_count"
filterColumns={['headquarters', 'funding_stage', 'person_age']}
generateSQLQuery={generateTeamCountSQL}
/>
)
}
@@ -0,0 +1,42 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateTechnicalFoundersSQL(activeFilters: Record<string, string>) {
const whereClauses = []
// Always filter out NULL values
whereClauses.push(`founders_are_technical IS NOT NULL`)
if (activeFilters.person_age !== 'unset') {
whereClauses.push(`person_age = '${activeFilters.person_age}'`)
}
if (activeFilters.money_raised !== 'unset') {
whereClauses.push(`money_raised = '${activeFilters.money_raised}'`)
}
const whereClause = `WHERE ${whereClauses.join('\n AND ')}`
return `SELECT
CASE
WHEN founders_are_technical = true THEN 'Yes'
WHEN founders_are_technical = false THEN 'No'
-- ELSE 'Unknown'
END AS founders_are_technical,
COUNT(*) AS total
FROM responses_2025
${whereClause}
GROUP BY founders_are_technical
ORDER BY total DESC;`
}
export function TechnicalFoundersChart() {
return (
<GenericChartWithQuery
title="Are the founder(s) at your current startup technical?"
targetColumn="founders_are_technical"
filterColumns={['person_age', 'money_raised']}
generateSQLQuery={generateTechnicalFoundersSQL}
chartType="pie"
/>
)
}
@@ -0,0 +1,32 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
function generateVectorDatabasesSQL(activeFilters: Record<string, string>) {
const whereClauses = []
if (activeFilters.databases !== 'unset') {
whereClauses.push(`'${activeFilters.databases}' = ANY(databases)`)
}
// TODO: Returns actual row values (e.g. "Weweb,Svelte,React") instead of just "Svelte"
// FilterDropdown should be able to handle these as separate, unique values
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
unnest(vector_databases) AS technology,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY technology
ORDER BY total DESC;
`
}
export function VectorDatabasesChart() {
return (
<GenericChartWithQuery
title="Which vector database(s) is your startup using? (Filtered)"
targetColumn="vector_databases"
filterColumns={['databases']}
generateSQLQuery={generateVectorDatabasesSQL}
/>
)
}
@@ -0,0 +1,39 @@
import { GenericChartWithQuery } from './GenericChartWithQuery'
// Generate SQL query for team count chart
function generateWorldOutlookSQL(activeFilters) {
const whereClauses = []
if (activeFilters.role !== 'unset') {
whereClauses.push(`role = '${activeFilters.role}'`)
}
if (activeFilters.funding_stage !== 'unset') {
whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`)
}
if (activeFilters.headquarters !== 'unset') {
whereClauses.push(`headquarters = '${activeFilters.headquarters}'`)
}
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
return `SELECT
world_outlook,
COUNT(*) AS total
--ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS percentage
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
GROUP BY world_outlook
ORDER BY total DESC;`
}
export function WorldOutlookChart() {
return (
<GenericChartWithQuery
title="Given the state of the world, are you…"
targetColumn="world_outlook"
filterColumns={['role', 'funding_stage', 'headquarters']}
generateSQLQuery={generateWorldOutlookSQL}
/>
)
}
@@ -1,18 +1,447 @@
export default (isMobile?: boolean) => ({
const stateOfStartupsData = (isMobile?: boolean) => ({
metaTitle: 'State of Startups 2025',
metaDescription:
'Take the survey and learn the latest trends among builders in tech stacks, AI usage, problem domains, and more.',
'The latest trends among builders in tech stacks, AI usage, problem domains, and more.',
metaImage: '/images/state-of-startups/2025/state-of-startups-og.png',
docsUrl: '',
heroSection: {
title: 'State of Startups 2025',
subheader: (
<>
There's never been a better time to build.
<br />
Take our State of Startups survey and tell us how you're building.
</>
),
subheader:
'We surveyed over 1,800 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.',
className: '[&_h1]:max-w-2xl',
},
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. They've done this before.",
pullQuote: {
quote:
"We’re a two-person team, both technical. It's not our first rodeo, and that experience helped us move way faster this time.",
author: 'John Doe',
authorPosition: 'Founder',
authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg',
},
sections: [
{
title: 'Roles and Experience',
description:
'Most builders are young, technical founders. Many are on their second or third company.',
stats: [
{ number: 83, unit: '%', label: 'Founders that are technical' },
{ number: 82, unit: '%', label: 'Founders under age 40' },
{ number: 33, unit: '%', 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: [
{ number: 91, unit: '%', label: 'Startups with less than 10 employees' },
{ number: 65, unit: '%', label: 'Startups under one year old' },
{ number: 6, unit: '%', 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: [
{ number: 17, unit: '%', label: 'Global startups based in Europe' },
{ number: 19, unit: '%', label: 'North American startups based in San Francisco' },
{ number: 9, unit: '%', label: 'North American startups based in New York City' },
],
charts: ['HeadquartersChart'],
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 experimenting in edtech for underserved regions. It’s early, but we’re learning fast.',
author: 'Sarah Johnson',
authorPosition: 'Founder',
authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.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: [
{ number: 38, unit: '%', label: 'Founders under 30 building in AI/ML' },
{ number: 33, unit: '%', label: 'Startups building for developers' },
{ number: 24, unit: '%', label: 'Startups targeting consumers' },
],
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: [
{ number: 59, unit: '%', label: 'Startups that have pivoted at least once' },
{ number: 58, unit: '%', label: 'Startups that are pre-revenue' },
{ number: 18, unit: '%', label: 'Startups that have 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:
'We’re experimenting in edtech for underserved regions. It’s early, but we’re learning fast.',
author: 'Sarah Johnson',
authorPosition: 'Founder',
authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg',
},
sections: [
{
title: 'Frameworks and Cloud Infra',
description:
'Supabase and Postgres dominate backend infra. React and Node top the 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: [
{
number: 75,
unit: '%',
label: 'Startups with a JavaScript framework in their frontend stack',
},
{
number: 62,
unit: '%',
label: 'Startups with Supabase in their cloud provider stack',
},
{ number: 61, unit: '%', 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: [
{ number: 57, unit: '%', label: 'Startups that pay for OpenAI or ChatGPT' },
{ number: 37, unit: '%', label: 'Startups that pay for Cursor' },
{ number: 5, unit: '%', 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 baked into the core. Semantic search and summarisation are what make the product usable.',
author: 'Tom Anderson',
authorPosition: 'Founder',
authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg',
},
sections: [
{
title: 'In-Product AI Use',
description:
'AI is increasingly embedded in product workflows, not bolted on. 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: [
{
number: 74,
unit: '%',
label: 'Startups using AI in their product',
},
{ number: 50, unit: '%', label: 'Startups building agents within their product' },
{
number: 34,
unit: '%',
label: 'Startups adding natural language interfaces to their product',
},
],
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: {
quote:
'I mostly lurk, but Twitter and Discord have been where I find the best tools and smartest minds.',
author: 'Emma Davis',
authorPosition: 'Founder',
authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg',
},
sections: [
{
title: 'Online Communities',
description:
'There is a healthy diaspora of important online communities used by respondents. That said, many are just lurking; few actively contribute to the discussion.',
stats: [
{ number: 58, unit: '%', label: 'Engineers using LinkedIn regularly' },
{ number: 46, unit: '%', label: 'Founders using X (Twitter) regularly' },
{ number: 7, unit: '%', 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, listen to The Diary of a CEO and Founders Podcasts. Tool discovery happens quite often via YouTube or GitHub. Physical event participation remains low.',
stats: [
{ number: 45, unit: '%', label: 'Respondents that listen to industry podcasts' },
{ number: 36, unit: '%', label: 'Respondents that have built a developer community' },
{
number: 20,
unit: '%',
label: 'Respondents that pay for at least one industry newsletter',
},
],
charts: ['NewIdeasChart'],
wordCloud: undefined,
summarizedAnswer: undefined,
rankedAnswersPair: [
{
label: 'Top podcasts listened to',
answers: ['The Diary of a CEO', 'Founders Podcasts', 'Lightcone'],
},
{
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: {
quote:
'Our first 10 customers came from one tweet. No landing page, no funnel. Just good timing and network.',
author: 'Sophie Lee',
authorPosition: 'Founder',
authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg',
},
sections: [
{
title: 'Initial Customers',
description:
'Founders earn their earliest customers through networks, communities, and inbound content. Not performance marketing. Paid acquisition rarely works early on.',
stats: [
{
number: 57,
unit: '%',
label: 'Startups that find their initial customers through word of mouth',
},
{ number: 47, unit: '%', label: 'Startups that engage users through social media' },
{ number: 39, unit: '%', label: 'Startups that are still experimenting with pricing' },
],
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: [
{
number: 75,
unit: '%',
label: 'Startups with their founders still directly responsible for sales',
},
{
number: 18,
unit: '%',
label: 'Startups that got their initial customers from personal networks',
},
{ number: 13, unit: '%', 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.',
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: [
{
number: 81,
unit: '%',
label: 'Respondents that evaluate potential tools via hands-on experience',
},
{
number: 29,
unit: '%',
label: 'Respondents that saved the most time via AI coding assistance',
},
{
number: 14,
unit: '%',
label: 'Respondents that saved the most time via BaaS platforms like Supabase',
},
],
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: [
{ number: 61, unit: '%', label: 'Founders that are optimistic' },
{ number: 32, unit: '%', label: 'Operations leads that are optimistic' },
{ number: 42, unit: '%', label: 'Other roles that are optimistic' },
],
charts: ['WorldOutlookChart'],
wordCloud: undefined,
summarizedAnswer: undefined,
rankedAnswersPair: undefined,
},
],
},
],
})
export default stateOfStartupsData
+251 -424
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { animate, createSpring, createTimeline, stagger } from 'animejs'
import Link from 'next/link'
import Image from 'next/image'
import { NextSeo } from 'next-seo'
import { Maximize2, X } from 'lucide-react'
import { Button, Checkbox, cn } from 'ui'
import { PopupFrame } from 'ui-patterns'
import { Button, Checkbox, cn, Card, CardHeader, CardTitle, CardContent } from 'ui'
import { Input } from 'ui/src/components/shadcn/ui/input'
import { Label } from 'ui/src/components/shadcn/ui/label'
import DefaultLayout from '~/components/Layouts/Default'
@@ -14,352 +15,208 @@ import { useSendTelemetryEvent } from '~/lib/telemetry'
import data from '~/data/surveys/state-of-startups-2025'
interface FormData {
email: string
terms: boolean
}
interface FormItem {
type: 'email' | 'checkbox'
label: string
placeholder: string
required: boolean
className?: string
component: typeof Input | typeof Checkbox
}
type FormConfig = {
[K in keyof FormData]: FormItem
}
const formConfig: FormConfig = {
email: {
type: 'email',
label: 'Email',
placeholder: 'Email',
required: true,
className: '',
component: Input,
},
terms: {
type: 'checkbox',
label: '',
placeholder: '',
required: true,
className: '',
component: Checkbox,
},
}
const defaultFormValue: FormData = {
email: '',
terms: false,
}
const isValidEmail = (email: string): boolean => {
const emailPattern = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/
return emailPattern.test(email)
}
import { SurveyChapter } from '~/components/SurveyResults/SurveyChapter'
import { SurveyChapterSection } from '~/components/SurveyResults/SurveyChapterSection'
function StateOfStartupsPage() {
const pageData = data()
const meta_title = pageData.metaTitle
const meta_description = pageData.metaDescription
const meta_image = pageData.metaImage
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 [formData, setFormData] = useState<FormData>(defaultFormValue)
const [honeypot, setHoneypot] = useState<string>('') // field to prevent spam
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
const [startTime, setStartTime] = useState<number>(0)
// Scroll detection to show floating ToC
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY
const heroElement = heroRef.current
const sendTelemetryEvent = useSendTelemetryEvent()
if (heroElement) {
const heroRect = heroElement.getBoundingClientRect()
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setErrors({})
setFormData((prev) => ({ ...prev, [name]: value }))
}
const handleReset = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault()
setFormData(defaultFormValue)
setSuccess(null)
setErrors({})
}
const validate = (): boolean => {
const newErrors: { [key in keyof FormData]?: string } = {}
// Check required fields
for (const key in formConfig) {
if (formConfig[key as keyof FormData].required && !formData[key as keyof FormData]) {
if (key === 'email') {
newErrors[key as keyof FormData] = `Email is required`
} else if (key === 'terms') {
newErrors[key as keyof FormData] = `You must agree to the terms`
// Show floating ToC when the hero section is completely out of view
if (heroRect.bottom < 0) {
setShowFloatingToc(true)
} else {
setShowFloatingToc(false)
setIsTocOpen(false)
}
}
}
// Validate email
if (formData.email && !isValidEmail(formData.email)) {
newErrors.email = 'Invalid email address'
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
setErrors(newErrors)
// Active chapter detection
useEffect(() => {
const handleScroll = () => {
const chapters = pageData.pageChapters
const scrollY = window.scrollY + 100 // Offset for better detection
// Return validation status, also check if honeypot is filled (indicating a bot)
return Object.keys(newErrors).length === 0 && honeypot === ''
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const currentTime = Date.now()
const timeElapsed = (currentTime - startTime) / 1000
// Spam prevention: Reject form if submitted too quickly (less than 3 seconds)
if (timeElapsed < 3) {
setErrors({ general: 'Submission too fast. Please fill the form correctly.' })
return
}
if (!validate()) {
return
}
setIsSubmitting(true)
setSuccess(null)
try {
const response = await fetch('/api-v2/submit-form-sos2025-newsletter', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
})
if (response.ok) {
setSuccess('Thank you for your submission!')
setFormData({ email: '', terms: false })
} else {
const errorData = await response.json()
setErrors({ general: `Submission failed: ${errorData.message}` })
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
}
}
} catch (error) {
setErrors({ general: 'An unexpected error occurred. Please try again.' })
} finally {
setIsSubmitting(false)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [pageData.pageChapters])
// 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])
// Floating Table of Contents component
const FloatingTableOfContents = () => {
const currentChapter = pageData.pageChapters[activeChapter - 1]
if (!showFloatingToc) return null
return (
<div
ref={tocRef}
className="fixed top-20 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300"
>
<div className="relative">
{/* Closed state - shows current chapter */}
<Button
type="default"
size="small"
onClick={() => setIsTocOpen(true)}
className={cn(
'flex flex-row gap-2 shadow-xl rounded-full px-2 pr-4',
isTocOpen && 'hidden'
)}
>
<div className={cn('flex items-center gap-2 font-mono uppercase text-xs')}>
{/* <motion.span
className="bg-surface-100 border border-surface-200 rounded-xl w-5 h-5 flex items-center justify-center text-foreground-light"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.1 }}
>
{activeChapter}
</motion.span> */}
<span className="bg-surface-100 border border-surface-200 rounded-xl w-5 h-5 flex items-center justify-center text-foreground-light">
{activeChapter}
</span>
<motion.span
key={currentChapter?.shortTitle}
className="text-foreground tracking-widest"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.1 }}
>
{currentChapter?.shortTitle}
</motion.span>
</div>
</Button>
{/* Open state - shows full table of contents */}
{isTocOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.86, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-[50%_25%] bg-background/75 backdrop-blur-lg border border-default rounded-xl shadow-xl overflow-hidden min-w-[280px]"
>
<ol className="max-h-[60vh] overflow-y-auto p-1 flex flex-col gap-1">
{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 py-2 rounded-lg text-sm transition-colors text-balance text-center',
chapterIndex + 1 === activeChapter
? 'bg-brand/10 text-brand-link dark:text-brand'
: 'text-foreground-light hover:text-foreground hover:bg-surface-300'
)}
>
{chapter.title}
</Link>
</motion.li>
))}
</ol>
</motion.div>
)}
</div>
</div>
)
}
useEffect(() => {
setStartTime(Date.now())
}, [])
// Inline Table of Contents component (expanded only)
const InlineTableOfContents = () => {
return (
<div className="bg-background/75 backdrop-blur-lg border border-default rounded-xl shadow-xl overflow-hidden min-w-[280px]">
<ol className="max-h-[60vh] overflow-y-auto p-1 flex flex-col gap-1">
{pageData.pageChapters.map((chapter, chapterIndex) => (
<li key={chapterIndex + 1}>
<Link
href={`#chapter-${chapterIndex + 1}`}
className="block py-2 rounded-lg text-sm transition-colors text-balance text-center text-foreground-light hover:text-foreground hover:bg-surface-300"
>
{chapter.title}
</Link>
</li>
))}
</ol>
</div>
)
}
return (
<>
<NextSeo
title={meta_title}
description={meta_description}
openGraph={{
title: meta_title,
description: meta_description,
url: `https://supabase.com/state-of-startups`,
images: [
{
url: meta_image,
},
],
}}
/>
<DefaultLayout className="!bg-alternative overflow-hidden sm:!overflow-visible">
<Hero {...pageData.heroSection} />
<SectionContainer>
<div className="flex flex-col text-center gap-4 py-8 items-center justify-center">
<h2 className="heading-gradient text-2xl sm:text-3xl xl:text-4xl">Stay in touch</h2>
<p className="mx-auto text-foreground-lighter w-full">
Sign up for our newsletter to be notified when the survey results are available.
</p>
<div className="w-full mt-4 flex items-center justify-center text-center gap-4">
{success ? (
<div className="flex flex-col h-full w-full min-w-[300px] gap-4 items-center justify-center opacity-0 transition-opacity animate-fade-in scale-1">
<p className="text-center text-sm">{success}</p>
<Button onClick={handleReset}>Reset</Button>
</div>
) : (
<form
noValidate
id="state-of-startups-form"
onSubmit={handleSubmit}
className="w-full max-w-md flex flex-col gap-4 items-center"
>
<div className="w-full flex flex-col sm:flex-row sm:items-center gap-2">
{/* Spam prevention */}
<input
type="text"
name="honeypot"
value={honeypot}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setHoneypot(e.target.value)
}
style={{ display: 'none' }}
aria-hidden="true"
/>
<Input
required
onChange={handleChange}
value={formData['email']}
name="email"
type="email"
placeholder="Email"
/>
<Button
htmlType="submit"
disabled={isSubmitting}
size="small"
onClick={() =>
sendTelemetryEvent({
action: 'register_for_state_of_startups_newsletter_clicked',
properties: { buttonLocation: 'State of Startups 2025 Newsletter Form' },
})
}
>
Register
</Button>
</div>
<div className="flex items-center gap-2 text-left">
<Checkbox
required
name="terms"
id="terms"
onChange={handleChange}
className="[&>input]:m-0"
/>
<Label htmlFor="terms" className="text-foreground-lighter leading-5">
We process your information in accordance with our{' '}
<Link href="/privacy" className="text-foreground-light hover:underline">
Privacy Policy
</Link>
.
</Label>
</div>
<div
className={cn(
'flex flex-nowrap text-right gap-1 items-center text-xs leading-none transition-opacity opacity-0 text-foreground-lighter',
errors['email'] && 'opacity-100 animate-fade-in',
errors['terms'] && 'opacity-100 animate-fade-in'
)}
>
{errors['email'] ? errors['email'] : errors['terms']}
</div>
</form>
)}
</div>
</div>
</SectionContainer>
</DefaultLayout>
</>
)
}
{/* <NextSeo {...pageData.seo} /> */}
<DefaultLayout className="!bg-alternative overflow-hidden">
{/* Floating version */}
<FloatingTableOfContents />
const Hero = (props: any) => {
const animRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!animRef.current) return
const strings = [
"What's your tech stack?",
"What's your favorite AI developer tool?",
'Which vector database are you using?',
'Are you building AI Agents?',
'Do you use OpenTelemetry?',
'Where do you go to learn?',
]
let currentIndex = 0
const animateText = () => {
const animatedText = animRef.current?.querySelector('#anim')
if (!animatedText) return
const currentText = strings[currentIndex]
animatedText.textContent = currentText
// Split by words and wrap each word, then wrap letters within each word
animatedText.innerHTML = currentText
.split(' ')
.map((word) => {
if (word.trim() === '') return ' '
const wrappedLetters = word.replace(
/\S/g,
"<span class='letter' style='opacity: 0; transform: translateY(-6px); display: inline-block;'>$&</span>"
)
return `<span class="word" style="display: inline-block; white-space: nowrap;">${wrappedLetters}</span>`
})
.join(' ')
createTimeline({
onComplete: () => {
currentIndex = (currentIndex + 1) % strings.length
setTimeout(() => {
animateOut()
}, 100)
},
}).add(animatedText.querySelectorAll('.letter'), {
opacity: [0, 1],
translateY: [-8, 0],
ease: createSpring({ stiffness: 150, damping: 15 }),
duration: 500,
delay: stagger(10),
})
}
const animateOut = () => {
const animatedText = animRef.current?.querySelector('#anim')
if (!animatedText) return
animate(animatedText.querySelectorAll('.letter'), {
opacity: [1, 0],
translateY: [0, 8],
ease: 'inExpo',
duration: 500,
delay: stagger(10),
onComplete: () => {
setTimeout(animateText, -100)
},
})
}
animateText()
return () => {}
}, [])
return (
<>
<div
className={cn(
'container relative w-full mx-auto px-6 py-8 md:py-16 sm:px-16 xl:px-20',
props.className
)}
>
<div
ref={animRef}
className="flex flex-col text-center items-center justify-center min-h-[600px] lg:min-h-[70vh]"
>
<div className="absolute overflow-hidden -mx-[15vw] sm:mx-0 inset-0 w-[calc(100%+30vw)] sm:w-full h-full col-span-12 lg:col-span-7 xl:col-span-6 xl:col-start-7 flex justify-center">
{/* Previously <Hero /> */}
<section ref={heroRef} className="relative w-full">
{/* SVG shapes container */}
<div className="absolute inset-0 -top-[30rem] xs:w-[calc(100%+50vw)] xs:-mx-[25vw]">
<svg
width="558"
height="392"
viewBox="0 0 558 392"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute w-full h-full inset-0 -top-40 lg:top-0 xl:-left-40 animate-pulse"
className="absolute w-full h-full inset-0 -top-40 animate-pulse"
style={{
animationDuration: '20000ms',
}}
@@ -379,21 +236,23 @@ const Hero = (props: any) => {
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(349.764 144.755) rotate(-132.179) scale(202.74 202.839)"
gradientTransform="translate(349.764 247.245) rotate(47.821) scale(202.74 202.839)"
>
<stop stopColor="hsl(var(--foreground-default))" />
<stop offset="1" stopColor="hsl(var(--foreground-default))" stopOpacity="0" />
<stop stopColor="hsl(var(--brand-200))" />
<stop offset="1" stopColor="hsl(var(--brand-200))" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
<div className="sm:w-full sm_h-full sm:flex sm:justify-center">
{/* <div className="sm:w-full sm_h-full sm:flex sm:justify-center"></div> */}
<div className="w-full h-full flex justify-center">
<svg
width="1119"
height="1119"
viewBox="0 0 1119 1119"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="sm:w-auto -mb-72 sm:-mt-60 md:-mt-40 lg:-mt-12 xl:mt-0 animate-spinner !ease-linear transform"
// className="sm:w-auto -mb-72 sm:-mt-60 md:-mt-40 lg:-mt-12 xl:mt-0 animate-spinner !ease-linear transform"
className="animate-spinner !ease-linear transform"
style={{
animationDuration: '20000ms',
}}
@@ -413,18 +272,16 @@ const Hero = (props: any) => {
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(571.212 579.87) rotate(122.182) scale(542.117 690.275)"
gradientTransform="translate(571.212 539.13) rotate(-57.818) scale(542.117 690.275)"
>
<stop stopColor="hsl(var(--border-muted))" />
<stop
offset="0.716346"
stopColor="hsl(var(--background-alternative-default))"
/>
<stop
offset="0.754808"
stopColor="hsl(var(--background-alternative-default))"
/>
<stop offset="1" stopColor="hsl(var(--border-strong))" />
{/* Inner core */}
<stop stopColor="hsl(var(--brand-200))" />
{/* Inner band */}
<stop offset="0.675" stopColor="hsl(var(--brand-200))" />
{/* Outer band */}
<stop offset="0.75" stopColor="hsl(var(--brand-300))" />
{/* Outermost band */}
<stop offset="1" stopColor="hsl(var(--brand-500))" />
</radialGradient>
<radialGradient
id="paint2_radial_183_1690"
@@ -432,10 +289,11 @@ const Hero = (props: any) => {
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(814.301 175.03) rotate(-38.9601) scale(142.974 294.371)"
gradientTransform="translate(814.301 944.97) rotate(141.0399) scale(142.974 294.371)"
>
<stop stopColor="hsl(var(--foreground-default))" />
<stop offset="1" stopColor="hsl(var(--foreground-default))" stopOpacity="0" />
{/* Outer slither ring */}
<stop stopColor="hsl(var(--brand-600))" />
<stop offset="1" stopColor="hsl(var(--brand-600))" stopOpacity="0" />
</radialGradient>
<clipPath id="clip0_183_1690">
<rect width="1119" height="1119" fill="white" />
@@ -443,90 +301,59 @@ const Hero = (props: any) => {
</defs>
</svg>
</div>
<svg
width="1096"
height="482"
viewBox="0 0 1096 482"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute min-w-full inset-0 top-auto z-10"
</div>
{/* Text container */}
<header className="container relative mt-[8rem] gap-[8rem] sm:gap-[14rem] w-full z-10 flex flex-col text-center justify-center items-center px-4 pb-16 mx-auto">
<h1 className="flex flex-col gap-4 items-center">
<span className="!leading-[90%] tracking-[-0.025em] text-8xl md:text-[14vw] lg:text-[12vw] xl:text-[10vw]">
State of
<br /> Startups
</span>
<span className="text-foreground text-2xl md:text-4xl leading-[100%]">2025</span>
</h1>
<div className="flex flex-col gap-4 max-w-prose">
<p className="p md:text-2xl">{pageData.heroSection.subheader}</p>
{/* Inline version - always expanded */}
<div className="relative flex justify-center mb-4">
<InlineTableOfContents />
</div>
<p className="p md:text-2xl">{pageData.heroSection.cta}</p>
</div>
</header>
</section>
{pageData.pageChapters.map((chapter, chapterIndex) => (
<>
<SurveyChapter
key={chapterIndex + 1}
number={chapterIndex + 1}
totalChapters={pageData.pageChapters.length}
shortTitle={chapter.shortTitle}
title={chapter.title}
description={chapter.description}
pullQuote={chapter.pullQuote}
>
<rect x="0.500488" width="1095" height="482" fill="url(#paint0_linear_183_1694)" />
<defs>
<linearGradient
id="paint0_linear_183_1694"
x1="922.165"
y1="63.3564"
x2="922.165"
y2="419.772"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="hsl(var(--background-alternative-default))" stopOpacity="0" />
<stop offset="1" stopColor="hsl(var(--background-alternative-default))" />
</linearGradient>
</defs>
</svg>
</div>
<div className="relative w-full z-10 flex flex-col items-center mx-auto">
<div className="flex gap-2 mb-4 md:mb-8">
<div className="w-11 h-11 relative flex items-center justify-center bg-default border rounded-lg">
<Image
src="/images/supabase-logo-icon.svg"
alt="Supabase icon"
width={60}
height={60}
className="w-6 h-6"
{chapter.sections.map((section, sectionIndex) => (
<SurveyChapterSection
key={sectionIndex + 1}
number={`${chapterIndex + 1}.${sectionIndex + 1}`}
title={section.title}
description={section.description}
stats={section.stats}
charts={section.charts}
wordCloud={section.wordCloud}
summarizedAnswer={section.summarizedAnswer}
rankedAnswersPair={section.rankedAnswersPair}
// pullQuote={section.pullQuote}
/>
</div>
</div>
<div>
{props.icon || props.title ? (
<div className="mb-2 flex justify-center items-center gap-3">
{props.title && (
<h1
className="text-brand font-mono uppercase tracking-widest text-sm"
key={`product-name-${props.title}`}
>
{props.title}
</h1>
)}
</div>
) : null}
</div>
<div className={cn('flex flex-col gap-4 items-center')}>
<div className="flex h-[150px] items-center">
<div
id="anim"
className="will-change-transform leading-[120%] text-4xl sm:text-5xl md:text-6xl min-h-[4rem] max-w-2xl [&_.letter]:transform"
>
State of Startups
</div>
</div>
<p className="p !text-foreground-light max-w-lg">{props.subheader}</p>
</div>
<div className="w-full sm:w-auto flex flex-col items-stretch sm:flex-row pt-2 sm:items-center gap-2">
<PopupFrame
trigger={
<Button size="small" asChild>
<span>Take the survey</span>
</Button>
}
className="[&_.modal-content]:min-h-[650px] [&_.modal-content]:!h-[75vh] [&_.modal-content]:flex [&_.modal-content]:flex-col"
>
<div className="w-full !h-full flex-1 flex flex-col">
<iframe
src={`https://form.typeform.com/to/si6Z7XlW?embedded=true`}
width="100%"
height="100%"
frameBorder="0"
className="w-full !min-h-full flex-1"
/>
</div>
</PopupFrame>
</div>
</div>
</div>
</div>
))}
</SurveyChapter>
</>
))}
</DefaultLayout>
</>
)
}